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?
Multi-Stage Dockerfile (Production Quality)
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.
| Tag | Use For | Example |
|---|---|---|
sha-<short> | CI builds (traceability) | sha-a1b2c3d |
v1.2.3 | Releases (semver) | v1.2.3 |
main-42 | Branch + run number | main-42 |
latest | Only 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
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 cache —
cache-from: type=ghareuses layers between runs - Multi-platform — build for amd64 + arm64 in one go
- Conditional push —
push: falseon 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
- Why use multi-stage Dockerfiles instead of single-stage?
- What does
cache-from: type=ghado? - Why is the image tagged with the git SHA instead of "latest"?
- What does Trivy do and when should it block the pipeline?
Reveal answers
- 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.
- Uses GitHub Actions' built-in cache to store Docker layers between workflow runs. Subsequent builds skip unchanged layers — typically 3-5× faster.
- 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.
- Trivy scans container images for known CVEs. Set
exit-code: 1+severity: HIGH,CRITICALto 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.