DeployU
Interviews / Cloud & DevOps / Design a CI/CD pipeline for building, testing, and deploying Docker images with proper tagging strategy.

Design a CI/CD pipeline for building, testing, and deploying Docker images with proper tagging strategy.

architecture CI/CD & Registry Interactive Quiz Code Examples

The Scenario

Your team needs to set up a CI/CD pipeline for a containerized application. Current problems:

  • Developers use docker build locally with inconsistent results
  • Images are tagged as latest, making rollbacks impossible
  • No automated testing before deployment
  • No vulnerability scanning
  • Manual deployments to production
  • No way to trace deployed images back to source commits

The Challenge

Design a complete CI/CD pipeline that builds, tests, scans, and deploys Docker images with a proper tagging strategy. Explain how to ensure reproducible builds and enable reliable rollbacks.

Wrong Approach

A junior engineer might tag everything as latest, skip testing because 'it works locally', build directly on the production server, store credentials in the Dockerfile, and deploy by SSH-ing into servers. These lead to untraceable deployments, broken production from untested code, security vulnerabilities, inconsistent builds, and no rollback capability.

Right Approach

A senior engineer implements a complete pipeline: semantic versioning with git SHA tags, multi-stage builds for reproducibility, automated testing before image creation, vulnerability scanning before pushing, immutable image tags, signed images for verification, and GitOps for deployment. Every deployed image is traceable to a specific commit and can be rolled back instantly.

Step 1: Image Tagging Strategy

# BAD: Mutable tags
myapp:latest          # Which version? Nobody knows
myapp:dev             # Changes constantly
myapp:stable          # When was this built?

# GOOD: Immutable, traceable tags
myapp:1.2.3                           # Semantic version
myapp:1.2.3-abc1234                   # Version + short SHA
myapp:sha-abc1234def5678              # Full commit SHA
myapp:pr-123                          # Pull request preview
myapp:1.2.3-abc1234-20240115-142030   # Version + SHA + timestamp

Step 2: Complete GitHub Actions Pipeline

name: Build, Test, and Deploy

on:
  push:
    branches: [main, develop]
    tags: ['v*']
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ============================================
  # Build and Test
  # ============================================
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      security-events: write

    outputs:
      image_tags: ${{ steps.meta.outputs.tags }}
      image_digest: ${{ steps.build.outputs.digest }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Login to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            # Branch name
            type=ref,event=branch
            # PR number
            type=ref,event=pr
            # Semantic version from tag
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            # Git SHA
            type=sha,prefix=sha-
            # Latest for main branch
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            BUILD_DATE=${{ github.event.head_commit.timestamp }}
            VCS_REF=${{ github.sha }}
            VERSION=${{ steps.meta.outputs.version }}

  # ============================================
  # Security Scan
  # ============================================
  scan:
    needs: build
    runs-on: ubuntu-latest

    steps:
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          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'

      - name: Fail on critical vulnerabilities
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          format: 'table'
          exit-code: '1'
          severity: 'CRITICAL'

  # ============================================
  # Integration Tests
  # ============================================
  test:
    needs: build
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Run integration tests
        run: |
          docker run --network host \
            -e DATABASE_URL=postgres://postgres:test@localhost:5432/test \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }} \
            npm run test:integration

  # ============================================
  # Deploy to Staging
  # ============================================
  deploy-staging:
    needs: [build, scan, test]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: staging

    steps:
      - name: Deploy to Kubernetes
        uses: azure/k8s-deploy@v4
        with:
          manifests: k8s/staging/
          images: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}

  # ============================================
  # Deploy to Production
  # ============================================
  deploy-production:
    needs: deploy-staging
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Deploy to Kubernetes
        uses: azure/k8s-deploy@v4
        with:
          manifests: k8s/production/
          images: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}

Step 3: Dockerfile with Build Metadata

# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS builder

ARG BUILD_DATE
ARG VCS_REF
ARG VERSION

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine AS production

# OCI Image Labels (standard metadata)
LABEL org.opencontainers.image.created="${BUILD_DATE}" \
      org.opencontainers.image.revision="${VCS_REF}" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.source="https://github.com/myorg/myapp"

WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

USER node
CMD ["node", "dist/server.js"]

Tagging Strategy Summary

TriggerTag PatternUse Case
Push to mainsha-abc1234, latestDevelopment builds
Pull requestpr-123Preview environments
Git tag v1.2.31.2.3, 1.2, 1Production releases
Schedulednightly-20240115Nightly builds

Rollback Strategy

# List available versions
docker images myapp --format "{{.Tag}}" | head -10

# Rollback to specific version
kubectl set image deployment/api api=myapp:1.2.2

# Or using Helm
helm rollback api-release 3

Image Signing (Cosign)

- name: Sign image with Cosign
  uses: sigstore/cosign-installer@v3

- name: Sign the image
  run: |
    cosign sign --yes \
      ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

Practice Question

Why is using only the 'latest' tag for Docker images problematic in production?