Modern Container Deployments
Containers provide consistency from development to production. Combined with GitHub Actions and Azure, you get a powerful, automated deployment pipeline.
In this guide, we will build a complete CI/CD pipeline that:
- Builds and tests containerized applications
- Pushes images to Azure Container Registry
- Deploys infrastructure using Bicep
- Manages secrets securely with Azure Key Vault
- Implements blue-green deployments
The Dockerfile
Let us start with a production-optimized multi-stage Dockerfile:
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files first for better layer caching
COPY package*.json ./
COPY pnpm-lock.yaml ./
# Install dependencies
RUN corepack enable && pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the application
RUN pnpm build
# Stage 2: Production
FROM node:20-alpine AS production
# Create non-root user for security
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
# Copy built assets from builder stage
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Start the application
CMD ["node", "dist/server.js"]GitHub Actions Workflow
Here is a comprehensive CI/CD workflow:
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
AZURE_CONTAINER_REGISTRY: myacr.azurecr.io
IMAGE_NAME: myapp
RESOURCE_GROUP: rg-myapp-prod
permissions:
id-token: write
contents: read
packages: write
jobs:
build-and-test:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.AZURE_CONTAINER_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build image for testing
uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: ${{ env.IMAGE_NAME }}:test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run container tests
run: |
docker run --rm ${{ env.IMAGE_NAME }}:test npm test
- name: Run security scan
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:test
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload scan results
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: 'trivy-results.sarif'
push-to-acr:
needs: build-and-test
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Login to ACR
run: az acr login --name ${{ env.AZURE_CONTAINER_REGISTRY }}
- name: Build and push to ACR
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.AZURE_CONTAINER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.AZURE_CONTAINER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-infrastructure:
needs: push-to-acr
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy Bicep template
uses: azure/arm-deploy@v1
with:
resourceGroupName: ${{ env.RESOURCE_GROUP }}
template: ./infra/main.bicep
parameters: imageTag=${{ github.sha }}
failOnStdErr: falseInfrastructure as Code with Bicep
Define your Azure infrastructure declaratively:
// infra/main.bicep
// Main Bicep template for container app infrastructure
targetScope = 'resourceGroup'
@description('Environment name')
@allowed(['dev', 'staging', 'prod'])
param environment string = 'prod'
@description('Azure region for resources')
param location string = resourceGroup().location
@description('Container image tag to deploy')
param imageTag string
@description('Container registry name')
param containerRegistry string = 'myacr'
// Variables
var appName = 'myapp'
var uniqueSuffix = uniqueString(resourceGroup().id)
var containerAppEnvName = 'cae-${appName}-${environment}'
var containerAppName = 'ca-${appName}-${environment}'
var logAnalyticsName = 'log-${appName}-${uniqueSuffix}'
// Log Analytics Workspace
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
name: logAnalyticsName
location: location
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
features: {
enableLogAccessUsingOnlyResourcePermissions: true
}
}
}
// Container App Environment
resource containerAppEnvironment 'Microsoft.App/managedEnvironments@2023-11-02-preview' = {
name: containerAppEnvName
location: location
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: logAnalytics.properties.customerId
sharedKey: logAnalytics.listKeys().primarySharedKey
}
}
zoneRedundant: environment == 'prod'
}
}
// Container App
resource containerApp 'Microsoft.App/containerApps@2023-11-02-preview' = {
name: containerAppName
location: location
identity: {
type: 'SystemAssigned'
}
properties: {
managedEnvironmentId: containerAppEnvironment.id
configuration: {
activeRevisionsMode: 'Multiple'
ingress: {
external: true
targetPort: 3000
transport: 'http'
traffic: [
{
weight: 100
latestRevision: true
}
]
}
registries: [
{
server: '${containerRegistry}.azurecr.io'
identity: 'system'
}
]
}
template: {
containers: [
{
name: appName
image: '${containerRegistry}.azurecr.io/${appName}:${imageTag}'
resources: {
cpu: json('0.5')
memory: '1Gi'
}
env: [
{
name: 'NODE_ENV'
value: 'production'
}
]
probes: [
{
type: 'Liveness'
httpGet: {
path: '/health'
port: 3000
}
initialDelaySeconds: 10
periodSeconds: 30
}
{
type: 'Readiness'
httpGet: {
path: '/ready'
port: 3000
}
initialDelaySeconds: 5
periodSeconds: 10
}
]
}
]
scale: {
minReplicas: environment == 'prod' ? 2 : 1
maxReplicas: 10
rules: [
{
name: 'http-scaling'
http: {
metadata: {
concurrentRequests: '100'
}
}
}
]
}
}
}
}
// Outputs
output containerAppUrl string = 'https://${containerApp.properties.configuration.ingress.fqdn}'
output containerAppId string = containerApp.idPowerShell Deployment Scripts
Automate deployments with PowerShell:
# scripts/Deploy-Infrastructure.ps1
#Requires -Version 7.0
#Requires -Modules Az.Accounts, Az.Resources, Az.ContainerRegistry
<#
.SYNOPSIS
Deploys Azure infrastructure for the container application.
.DESCRIPTION
This script deploys the Bicep template and configures the container app
with proper secrets and identity assignments.
.PARAMETER Environment
Target environment (dev, staging, prod)
.PARAMETER ImageTag
Docker image tag to deploy
.EXAMPLE
./Deploy-Infrastructure.ps1 -Environment prod -ImageTag abc123
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('dev', 'staging', 'prod')]
[string]$Environment,
[Parameter(Mandatory = $true)]
[string]$ImageTag,
[Parameter()]
[string]$Location = 'eastus2'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Configuration
$Config = @{
ResourceGroupName = "rg-myapp-$Environment"
TemplateFile = Join-Path $PSScriptRoot '../infra/main.bicep'
ContainerRegistry = 'myacr'
KeyVaultName = "kv-myapp-$Environment"
}
function Write-Step {
param([string]$Message)
Write-Host "`n> $Message" -ForegroundColor Cyan
}
function Test-AzureConnection {
Write-Step 'Validating Azure connection'
try {
$context = Get-AzContext
if (-not $context) {
throw 'Not connected to Azure'
}
Write-Host " Connected to: $($context.Subscription.Name)" -ForegroundColor Green
}
catch {
Write-Error 'Please run Connect-AzAccount first'
exit 1
}
}
function Deploy-BicepTemplate {
Write-Step 'Deploying Bicep template'
$deploymentParams = @{
ResourceGroupName = $Config.ResourceGroupName
TemplateFile = $Config.TemplateFile
environment = $Environment
imageTag = $ImageTag
containerRegistry = $Config.ContainerRegistry
location = $Location
Verbose = $true
}
$deployment = New-AzResourceGroupDeployment @deploymentParams
if ($deployment.ProvisioningState -ne 'Succeeded') {
throw "Deployment failed: $($deployment.ProvisioningState)"
}
Write-Host " Deployment succeeded!" -ForegroundColor Green
Write-Host " Container App URL: $($deployment.Outputs.containerAppUrl.Value)"
return $deployment
}
function Set-ContainerAppIdentity {
param([string]$ContainerAppId)
Write-Step 'Configuring managed identity permissions'
# Get the container app managed identity
$containerApp = Get-AzResource -ResourceId $ContainerAppId
$principalId = $containerApp.Identity.PrincipalId
# Grant ACR pull permissions
$acrId = (Get-AzContainerRegistry -Name $Config.ContainerRegistry).Id
New-AzRoleAssignment `
-ObjectId $principalId `
-RoleDefinitionName 'AcrPull' `
-Scope $acrId `
-ErrorAction SilentlyContinue
# Grant Key Vault access
Set-AzKeyVaultAccessPolicy `
-VaultName $Config.KeyVaultName `
-ObjectId $principalId `
-PermissionsToSecrets Get, List
Write-Host ' Identity permissions configured' -ForegroundColor Green
}
function Invoke-HealthCheck {
param([string]$Url)
Write-Step 'Running health check'
$maxAttempts = 10
$attempt = 0
do {
$attempt++
Write-Host " Attempt $attempt of $maxAttempts..."
try {
$response = Invoke-RestMethod -Uri "$Url/health" -TimeoutSec 5
if ($response.status -eq 'healthy') {
Write-Host ' Health check passed!' -ForegroundColor Green
return $true
}
}
catch {
Write-Host " Waiting for app to start..." -ForegroundColor Yellow
}
Start-Sleep -Seconds 10
} while ($attempt -lt $maxAttempts)
Write-Warning 'Health check did not pass within timeout'
return $false
}
# Main execution
try {
Write-Host '=====================================' -ForegroundColor Magenta
Write-Host " Deploying to $Environment" -ForegroundColor Magenta
Write-Host '=====================================' -ForegroundColor Magenta
Test-AzureConnection
$deployment = Deploy-BicepTemplate
Set-ContainerAppIdentity -ContainerAppId $deployment.Outputs.containerAppId.Value
Invoke-HealthCheck -Url $deployment.Outputs.containerAppUrl.Value
Write-Host "`nDeployment complete!" -ForegroundColor Green
}
catch {
Write-Error "Deployment failed: $_"
exit 1
}Bash Utility Scripts
Cross-platform shell scripts for common tasks:
#!/usr/bin/env bash
# scripts/container-utils.sh
# Container utility functions for local development and CI
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
REGISTRY="myacr.azurecr.io"
IMAGE_NAME="myapp"
CONTAINER_NAME="myapp-local"
log_info() {
echo -e "${BLUE}i${NC} $1"
}
log_success() {
echo -e "${GREEN}+${NC} $1"
}
log_warning() {
echo -e "${YELLOW}!${NC} $1"
}
log_error() {
echo -e "${RED}x${NC} $1" >&2
}
# Build the Docker image
build() {
local tag="${1:-latest}"
log_info "Building image: ${IMAGE_NAME}:${tag}"
docker build \
--tag "${IMAGE_NAME}:${tag}" \
--build-arg BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
--build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" \
--progress=plain \
.
log_success "Build complete: ${IMAGE_NAME}:${tag}"
}
# Run the container locally
run() {
local port="${1:-3000}"
# Stop existing container if running
if docker ps -q -f name="${CONTAINER_NAME}" | grep -q .; then
log_warning "Stopping existing container"
docker stop "${CONTAINER_NAME}" > /dev/null
fi
# Remove existing container
docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
log_info "Starting container on port ${port}"
docker run -d \
--name "${CONTAINER_NAME}" \
--publish "${port}:3000" \
--env NODE_ENV=development \
--mount type=bind,source="$(pwd)/config",target=/app/config,readonly \
"${IMAGE_NAME}:latest"
log_success "Container running at http://localhost:${port}"
log_info "View logs: docker logs -f ${CONTAINER_NAME}"
}
# Push to Azure Container Registry
push() {
local tag="${1:-latest}"
local full_image="${REGISTRY}/${IMAGE_NAME}:${tag}"
log_info "Logging into Azure Container Registry"
az acr login --name "${REGISTRY%%.*}"
log_info "Tagging image for registry"
docker tag "${IMAGE_NAME}:${tag}" "${full_image}"
log_info "Pushing to ${full_image}"
docker push "${full_image}"
log_success "Push complete: ${full_image}"
}
# Run tests in container
test() {
log_info "Running tests in container"
docker run --rm \
--env NODE_ENV=test \
"${IMAGE_NAME}:latest" \
npm test
log_success "Tests passed"
}
# Security scan with Trivy
scan() {
local tag="${1:-latest}"
log_info "Scanning ${IMAGE_NAME}:${tag} for vulnerabilities"
if ! command -v trivy &> /dev/null; then
log_error "Trivy not installed. Install with: brew install trivy"
return 1
fi
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
"${IMAGE_NAME}:${tag}"
log_success "No critical vulnerabilities found"
}
# Clean up local Docker resources
cleanup() {
log_info "Cleaning up Docker resources"
# Stop and remove container
docker stop "${CONTAINER_NAME}" 2>/dev/null || true
docker rm "${CONTAINER_NAME}" 2>/dev/null || true
# Remove dangling images
docker image prune -f
# Remove old versions of our image
docker images "${IMAGE_NAME}" --format '{{.ID}}' | \
tail -n +3 | \
xargs -r docker rmi -f
log_success "Cleanup complete"
}
# Show container logs
logs() {
docker logs -f "${CONTAINER_NAME}"
}
# Execute command in running container
exec_cmd() {
docker exec -it "${CONTAINER_NAME}" "$@"
}
# Show usage
usage() {
cat << EOF
Container Utilities
Usage: $(basename "$0") <command> [options]
Commands:
build [tag] Build the Docker image
run [port] Run container locally (default port: 3000)
push [tag] Push image to Azure Container Registry
test Run tests in container
scan [tag] Security scan with Trivy
cleanup Remove containers and prune images
logs Tail container logs
exec <cmd> Execute command in container
Examples:
$(basename "$0") build v1.0.0
$(basename "$0") run 8080
$(basename "$0") push latest
$(basename "$0") exec sh
EOF
}
# Main command router
main() {
local command="${1:-}"
shift || true
case "${command}" in
build) build "$@" ;;
run) run "$@" ;;
push) push "$@" ;;
test) test ;;
scan) scan "$@" ;;
cleanup) cleanup ;;
logs) logs ;;
exec) exec_cmd "$@" ;;
help|--help|-h|'')
usage
exit 0
;;
*)
log_error "Unknown command: ${command}"
usage
exit 1
;;
esac
}
main "$@"Docker Compose for Local Development
Complete local development environment:
# docker-compose.yml
version: '3.9'
services:
app:
build:
context: .
target: production
container_name: myapp
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
volumes:
- ./config:/app/config:ro
networks:
- app-network
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:16-alpine
container_name: myapp-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
volumes:
- postgres-data:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "5432:5432"
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
container_name: myapp-cache
command: redis-server --appendonly yes
volumes:
- redis-data:/data
ports:
- "6379:6379"
networks:
- app-network
# Development tools
adminer:
image: adminer:latest
container_name: myapp-adminer
ports:
- "8080:8080"
networks:
- app-network
profiles:
- tools
volumes:
postgres-data:
redis-data:
networks:
app-network:
driver: bridgeSecurity Best Practice
Always use workload identity federation for GitHub Actions instead of storing service principal secrets. This eliminates long-lived credentials and leverages Azure AD token-based authentication.
Key Takeaways
Multi-Stage Builds
Separate build and runtime stages for smaller, more secure production images.
Workload Identity
Use OIDC federation between GitHub and Azure for secretless authentication.
Infrastructure as Code
Bicep templates ensure reproducible, version-controlled infrastructure.
Blue-Green Deployments
Container Apps revision model enables zero-downtime deployments.


Comments (0)
No comments yet. Be the first to share your thoughts!
Leave a Comment
Sign in with Google to leave a comment.