Building a Docker CI/CD Pipeline: From Code to Production

A well-designed CI/CD pipeline for Docker containers automates the journey from code commit to production deployment. Every step in this pipeline serves a purpose: building ensures reproducibility, testing catches bugs early, scanning prevents vulnerable images from reaching production, and automated deployment eliminates human error. Together, these stages create a reliable path that turns code changes into running containers with confidence.

This guide walks through each pipeline stage with concrete examples for GitHub Actions and GitLab CI, the two most popular CI/CD platforms for containerized workflows.

Pipeline Overview

A complete Docker CI/CD pipeline consists of five stages:

  1. Build — Create the Docker image from source code
  2. Test — Run unit tests, integration tests, and linting inside the container
  3. Scan — Check the image for known vulnerabilities
  4. Push — Upload the verified image to a container registry
  5. Deploy — Update the running containers in production

Each stage acts as a quality gate. If any stage fails, the pipeline stops and the change does not reach production. This fail-fast approach catches problems at the earliest (and cheapest) possible stage.

Stage 1: Building Docker Images

The build stage creates a reproducible Docker image from your application code and Dockerfile.

Optimizing the Dockerfile for CI/CD

A CI-optimized Dockerfile uses multi-stage builds and layer caching effectively:

# Stage 1: Dependencies (cached unless package files change)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production

# Stage 2: Build (cached unless source code changes)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Production image (minimal)
FROM node:20-alpine AS production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

USER appuser
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

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

Image Tagging Strategy

A consistent tagging strategy is essential for traceability and rollbacks:

# Tag with git SHA for traceability
docker build -t myapp:$(git rev-parse --short HEAD) .

# Tag with semantic version for releases
docker build -t myapp:1.2.3 .

# Tag with branch name for development
docker build -t myapp:feature-auth .

# Always update the 'latest' tag for convenience
docker tag myapp:1.2.3 myapp:latest

The recommended tagging convention:

Tag When Example
sha-abc1234 Every commit Exact code traceability
v1.2.3 Releases/tags Semantic version for production
main Main branch builds Latest stable development build
latest Latest release only Points to newest stable version

Stage 2: Testing in Containers

Running tests inside containers ensures your test environment matches production exactly.

# Run unit tests inside the build stage
FROM node:20-alpine AS test
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run lint
RUN npm run test:unit

# Integration tests using Docker Compose
# docker-compose.test.yml
services:
  test:
    build:
      context: .
      target: test
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://test:test@postgres:5432/testdb
      REDIS_URL: redis://redis:6379
    command: npm run test:integration

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: testdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

Run the test suite:

# Run all tests using Docker Compose
docker compose -f docker-compose.test.yml run --rm --build test

# Capture exit code for CI
docker compose -f docker-compose.test.yml run --rm --build test; EXIT_CODE=$?
docker compose -f docker-compose.test.yml down -v
exit $EXIT_CODE

Stage 3: Vulnerability Scanning

Scanning images for known vulnerabilities (CVEs) before pushing them to a registry prevents deploying insecure software.

# Using Trivy (recommended — fast, accurate, well-maintained)
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest

# Using Docker Scout (integrated into Docker CLI)
docker scout cves myapp:latest --exit-code --only-severity critical,high

# Using Grype
grype myapp:latest --fail-on high

A comprehensive scan policy:

# Scan for OS vulnerabilities
trivy image --vuln-type os myapp:latest

# Scan for application dependency vulnerabilities
trivy image --vuln-type library myapp:latest

# Generate a detailed report (SARIF format for GitHub)
trivy image --format sarif --output results.sarif myapp:latest

# Check for misconfigurations in Dockerfile
trivy config --exit-code 1 .

Tip: Set up a policy where CRITICAL vulnerabilities block the pipeline immediately, HIGH vulnerabilities block after a 7-day grace period, and MEDIUM vulnerabilities are tracked but don't block deployment. This balances security with development velocity.

Stage 4: Pushing to a Registry

After testing and scanning pass, push the image to your container registry.

Registry Options

Registry Type Best For
Docker Hub Cloud Public images, open source projects
GitHub Container Registry (ghcr.io) Cloud GitHub-hosted projects
GitLab Container Registry Cloud/Self-hosted GitLab-hosted projects
AWS ECR Cloud AWS-based deployments
Self-hosted Registry Self-hosted Full control, air-gapped environments

Self-Hosted Registry

For maximum control and to avoid rate limits, run your own registry:

# Deploy a self-hosted registry
docker run -d \
  --name registry \
  --restart always \
  -p 5000:5000 \
  -v registry_data:/var/lib/registry \
  -e REGISTRY_STORAGE_DELETE_ENABLED=true \
  registry:2

# Push to self-hosted registry
docker tag myapp:latest registry.internal:5000/myapp:latest
docker push registry.internal:5000/myapp:latest

Stage 5: Deployment

The deployment stage updates running containers with the new image. The approach depends on your infrastructure.

Docker Compose Deployment

For single-host deployments using Docker Compose:

#!/bin/bash
# deploy.sh — Zero-downtime deployment with Docker Compose

set -euo pipefail

IMAGE_TAG="${1:?Usage: deploy.sh IMAGE_TAG}"
COMPOSE_FILE="docker-compose.prod.yml"

echo "Deploying $IMAGE_TAG..."

# Pull the new image
docker compose -f $COMPOSE_FILE pull

# Update the running service with the new image
IMAGE_TAG=$IMAGE_TAG docker compose -f $COMPOSE_FILE up -d --no-deps --build app

# Wait for health check to pass
echo "Waiting for health check..."
RETRIES=30
until docker compose -f $COMPOSE_FILE ps app | grep -q "healthy"; do
  RETRIES=$((RETRIES - 1))
  if [ $RETRIES -le 0 ]; then
    echo "Health check failed. Rolling back..."
    docker compose -f $COMPOSE_FILE rollback
    exit 1
  fi
  sleep 2
done

echo "Deployment successful: $IMAGE_TAG"

Docker Swarm Rolling Updates

Docker Swarm provides built-in rolling update support:

# Update a service with rolling deployment
docker service update \
  --image myapp:v1.2.3 \
  --update-parallelism 1 \
  --update-delay 10s \
  --update-failure-action rollback \
  --update-monitor 30s \
  --update-order start-first \
  myapp

# The flags explained:
# --update-parallelism 1    Update one container at a time
# --update-delay 10s        Wait 10s between each container update
# --update-failure-action   Rollback automatically on failure
# --update-monitor 30s      Monitor for 30s after each update
# --update-order start-first Start new container before stopping old one

Complete GitHub Actions Pipeline

Here is a production-ready GitHub Actions workflow that implements all five stages:

# .github/workflows/docker-pipeline.yml
name: Docker CI/CD Pipeline

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

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

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      security-events: write

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

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

      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha

      - name: Build test image
        uses: docker/build-push-action@v5
        with:
          context: .
          target: test
          load: true
          tags: ${{ env.IMAGE_NAME }}:test
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Run tests
        run: |
          docker compose -f docker-compose.test.yml run --rm test

      - name: Build production image
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          load: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Scan for vulnerabilities
        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'
          exit-code: '1'

      - name: Upload scan results to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: 'trivy-results.sarif'

      - name: Push to registry
        if: github.event_name != 'pull_request'
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          script: |
            cd /opt/myapp
            docker compose pull
            docker compose up -d --no-deps app
            sleep 10
            docker compose ps app | grep -q "healthy" || exit 1

Complete GitLab CI Pipeline

The equivalent pipeline for GitLab CI:

# .gitlab-ci.yml
stages:
  - build
  - test
  - scan
  - push
  - deploy

variables:
  DOCKER_HOST: tcp://docker:2376
  DOCKER_TLS_CERTDIR: "/certs"
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

build:
  stage: build
  image: docker:24-dind
  services:
    - docker:24-dind
  script:
    - docker build --target production -t $IMAGE_TAG .
    - docker save $IMAGE_TAG -o image.tar
  artifacts:
    paths:
      - image.tar
    expire_in: 1 hour

test:
  stage: test
  image: docker:24-dind
  services:
    - docker:24-dind
  script:
    - docker load -i image.tar
    - docker build --target test -t ${IMAGE_TAG}-test .
    - docker compose -f docker-compose.test.yml run --rm test
  dependencies:
    - build

scan:
  stage: scan
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    - trivy image --exit-code 1 --severity HIGH,CRITICAL --input image.tar
  dependencies:
    - build
  allow_failure: false

push:
  stage: push
  image: docker:24-dind
  services:
    - docker:24-dind
  script:
    - docker load -i image.tar
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker push $IMAGE_TAG
    - docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:latest
    - docker push $CI_REGISTRY_IMAGE:latest
  dependencies:
    - build
  only:
    - main
    - tags

deploy:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$DEPLOY_SSH_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - echo "$DEPLOY_HOST_KEY" >> ~/.ssh/known_hosts
  script:
    - ssh $DEPLOY_USER@$DEPLOY_HOST "
        cd /opt/myapp &&
        docker compose pull &&
        docker compose up -d --no-deps app &&
        sleep 10 &&
        docker compose ps app | grep -q healthy"
  only:
    - main
  environment:
    name: production
  when: manual

Registry Management

A growing registry consumes significant storage. Implement cleanup policies to keep it manageable:

# Delete old tags from a self-hosted registry
# Keep only the last 10 tags for each image
REGISTRY_URL="https://registry.internal:5000"
REPO="myapp"

# List tags
TAGS=$(curl -s "$REGISTRY_URL/v2/$REPO/tags/list" | jq -r '.tags[]' | sort -V)
KEEP=10
DELETE_COUNT=$(($(echo "$TAGS" | wc -l) - KEEP))

if [ $DELETE_COUNT -gt 0 ]; then
  echo "$TAGS" | head -n $DELETE_COUNT | while read TAG; do
    DIGEST=$(curl -s -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
      "$REGISTRY_URL/v2/$REPO/manifests/$TAG" -I | grep Docker-Content-Digest | awk '{print $2}' | tr -d '\r')
    curl -s -X DELETE "$REGISTRY_URL/v2/$REPO/manifests/$DIGEST"
    echo "Deleted: $REPO:$TAG"
  done
fi

# Run garbage collection to reclaim disk space
docker exec registry bin/registry garbage-collect /etc/docker/registry/config.yml

GitHub Container Registry Cleanup

# Using gh CLI to clean up old package versions
gh api -X GET /user/packages/container/myapp/versions \
  --jq '.[] | select(.metadata.container.tags | length == 0) | .id' | \
  while read id; do
    gh api -X DELETE /user/packages/container/myapp/versions/$id
  done

Rolling Updates and Rollback Strategies

Blue-Green Deployment

Deploy the new version alongside the old one, then switch traffic:

# docker-compose.prod.yml with blue-green support
services:
  app-blue:
    image: myapp:${BLUE_TAG:-latest}
    networks:
      - web

  app-green:
    image: myapp:${GREEN_TAG:-latest}
    networks:
      - web

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx-${ACTIVE_COLOR:-blue}.conf:/etc/nginx/conf.d/default.conf:ro
    networks:
      - web

networks:
  web:

Canary Deployment

Route a small percentage of traffic to the new version before rolling out fully:

# With Docker Swarm, use replica counts for canary
# Deploy 1 new replica alongside 9 old ones (10% canary)
docker service update --image myapp:v2.0 --update-parallelism 1 --replicas 10 myapp

# In nginx, use weighted upstream for canary routing
upstream backend {
    server app-stable:3000 weight=9;
    server app-canary:3000 weight=1;
}

Quick Rollback

# Docker Compose rollback
docker compose up -d --no-deps -e IMAGE_TAG=v1.2.2 app

# Docker Swarm automatic rollback
docker service update --rollback myapp

# Manual rollback by retagging
docker tag myapp:v1.2.2 myapp:latest
docker compose up -d

Tip: usulnet's interface makes rollbacks straightforward. You can see the current image tag for each container, view the deployment history, and trigger a redeploy with a previous image version directly from the dashboard. This is particularly valuable during incidents when speed matters.

Pipeline Best Practices

  • Cache aggressively: Use BuildKit cache mounts and CI layer caching to speed up builds. A well-cached build should take under 2 minutes.
  • Pin base image versions: Use node:20.11-alpine instead of node:20-alpine to prevent unexpected base image changes from breaking builds.
  • Sign your images: Use cosign or Docker Content Trust to ensure image integrity from build to deployment.
  • Use build arguments for configuration: Pass build-time configuration via --build-arg rather than hardcoding values in the Dockerfile.
  • Test the actual production image: Don't test a separate test image and then build a different production image. Build the production image first, then run tests against it.
  • Implement health check gates: After deployment, wait for the new container's health check to pass before considering the deployment successful.
  • Keep deployment credentials separate: Store SSH keys and registry credentials in your CI platform's secret management, never in the repository.

A robust Docker CI/CD pipeline transforms your development workflow from manual, error-prone deployments to automated, consistent releases. Start with a simple build-test-push pipeline and incrementally add scanning, automated deployment, and advanced rollout strategies. The investment in pipeline setup pays for itself within the first few deployments by catching issues early, ensuring consistency, and enabling your team to ship with confidence.