Deploying files works. But containers are the standard for Kubernetes. In this lesson you'll write a production Dockerfile, build it in CI, scan for vulnerabilities, and push to Azure Container Registry.

Why Containers for Deployment?

File-based deploy (App Service) • Runtime configured separately from code • "Works on my machine" still possible • Different envs may have different system libs Container deploy (K8s, ACA) • EVERYTHING in one image (code + runtime + deps) • Identical everywhere: dev = CI = prod • Immutable artifact — deploy any version instantly

Multi-Stage Dockerfile (Production Quality)

Stage 1: deps node:20-alpine COPY package*.json RUN npm ci --production ~200 MB (includes npm) COPY --from Stage 2: runtime node:20-alpine COPY --from=deps node_modules COPY src/ ~80 MB final image! Single stage node:20 (full) ~950 MB 😱 Includes build tools, npm cache
Multi-stage builds copy only what's needed into the final image. 10× smaller, faster to pull, less attack surface.

Create your Dockerfile

# Dockerfile
# ═══ Stage 1: Install production deps ═══
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

# ═══ Stage 2: Production runtime ═══
FROM node:20-alpine AS runtime

# Security: non-root user
RUN addgroup -g 1001 -S app && adduser -S app -u 1001 -G app
WORKDIR /app

# Copy deps from stage 1
COPY --from=deps /app/node_modules ./node_modules
COPY src/ ./src/
COPY package.json ./

# Own the files
RUN chown -R app:app /app
USER app

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --spider -q http://localhost:3000/health || exit 1

ENV NODE_ENV=production
CMD ["node", "src/server.js"]

Create .dockerignore

node_modules
.git
.github
coverage
reports
tests
*.md
.eslintrc*

Test locally

docker build -t cicd-mastery:local .
docker run -p 3000:3000 cicd-mastery:local
# Visit http://localhost:3000/health

Image Tagging Strategy

myapp:latest

Which version is this? When was it built? Can't rollback to "the one before latest."

myapp:sha-a1b2c3d

Tied to exact commit. Immutable. Rollback = deploy previous SHA. Full traceability.

TagUse ForExample
sha-<short>CI builds (traceability)sha-a1b2c3d
v1.2.3Releases (semver)v1.2.3
main-42Branch + run numbermain-42
latestOnly as convenience, never for deploys

🏋️ Container Build Pipeline

Create ACR (one-time setup)

ACR_NAME="acrcicdmastery$(openssl rand -hex 3)"

az acr create \
  --name $ACR_NAME \
  --resource-group rg-cicd-mastery \
  --sku Basic

# Grant your GitHub Actions SP the AcrPush role
ACR_ID=$(az acr show --name $ACR_NAME --query id -o tsv)
az role assignment create \
  --assignee $APP_ID \
  --role "AcrPush" \
  --scope $ACR_ID

# Add as GitHub variables:
# ACR_NAME = acrcicdmasteryXXX
# ACR_LOGIN_SERVER = acrcicdmasteryXXX.azurecr.io

The workflow

# .github/workflows/container.yml
name: Container Build

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  id-token: write
  contents: read

env:
  IMAGE: cicd-mastery-api

jobs:
  build-push:
    name: 🐳 Build & Push
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.tag.outputs.value }}
    steps:
      - uses: actions/checkout@v4

      - name: Set image tag
        id: tag
        run: echo "value=sha-$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Azure Login
        if: github.event_name == 'push'
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Login to ACR
        if: github.event_name == 'push'
        run: az acr login --name ${{ vars.ACR_NAME }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name == 'push' }}  # Only push on main
          tags: |
            ${{ vars.ACR_LOGIN_SERVER }}/${{ env.IMAGE }}:${{ steps.tag.outputs.value }}
            ${{ vars.ACR_LOGIN_SERVER }}/${{ env.IMAGE }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  scan:
    name: 🔍 Vulnerability Scan
    needs: build-push
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - name: Azure Login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - run: az acr login --name ${{ vars.ACR_NAME }}
      - name: Trivy scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ vars.ACR_LOGIN_SERVER }}/${{ env.IMAGE }}:${{ needs.build-push.outputs.image-tag }}
          severity: 'HIGH,CRITICAL'
          exit-code: '1'

The Container Pipeline Flow

Build image docker build Push to ACR tagged: sha-abc Scan (Trivy) HIGH/CRITICAL Ready to deploy acr.io/api:sha-abc Deploy to K8s (next lesson!)

Key Details

Why docker/build-push-action?

You could run: docker build && docker push. But the action gives you:

  • Buildx — faster, supports caching natively
  • GHA cachecache-from: type=gha reuses layers between runs
  • Multi-platform — build for amd64 + arm64 in one go
  • Conditional pushpush: false on PRs (just validate the build)

Why scan AFTER push?

Trivy needs to pull the image from a registry to scan it. The pattern is: push → scan → only deploy if scan passes.

🧠 Recall Check

  1. Why use multi-stage Dockerfiles instead of single-stage?
  2. What does cache-from: type=gha do?
  3. Why is the image tagged with the git SHA instead of "latest"?
  4. What does Trivy do and when should it block the pipeline?
Reveal answers
  1. Final image only contains runtime essentials (no build tools, npm cache, dev deps). Result: ~80MB vs ~950MB. Smaller = faster pulls, less attack surface, cheaper storage.
  2. Uses GitHub Actions' built-in cache to store Docker layers between workflow runs. Subsequent builds skip unchanged layers — typically 3-5× faster.
  3. Traceability and immutability. SHA ties the image to an exact commit. You can always trace what code is in a running container. "latest" is ambiguous and mutable.
  4. Trivy scans container images for known CVEs. Set exit-code: 1 + severity: HIGH,CRITICAL to block deployment if serious vulnerabilities are found in your base image or dependencies.
You now have a containerised application in ACR, scanned for vulnerabilities, tagged with its git SHA. This image is what gets deployed to Kubernetes. Next lesson: deploying it to AKS.