Container-First CI/CD with GitHub Actions and Azure
DevOps

Container-First CI/CD with GitHub Actions and Azure

Build a production-grade CI/CD pipeline using GitHub Actions, Docker, and Azure Container Registry with infrastructure as code using Bicep.

Nathan Duff

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:

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:

yaml
# .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: false

Infrastructure as Code with Bicep

Define your Azure infrastructure declaratively:

bicep
// 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.id

PowerShell Deployment Scripts

Automate deployments with PowerShell:

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:

bash
#!/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:

yaml
# 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: bridge

Security 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.