← Back to DevBytes

Docker with CI/CD Pipelines: Production Guide

What Is Docker with CI/CD Pipelines?

Docker with CI/CD pipelines refers to the practice of integrating containerized application builds, tests, and deployments into an automated continuous integration and continuous delivery (or deployment) workflow. In this model, Docker containers become the standard unit of software that flows through the entire pipeline—from a developer's local machine, through automated testing and quality gates, all the way to production deployment.

A typical CI/CD pipeline that uses Docker will:

The key insight is that the same Docker image that was tested in CI is the exact same artifact deployed to production. This eliminates the classic "it worked on my machine" problem and ensures complete environment parity across all stages of the delivery lifecycle.

Why Docker in CI/CD Matters for Production

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Using Docker as the core artifact in your CI/CD pipeline brings several critical advantages for production systems:

Immutable Deployments

Docker images are immutable. Once built and tagged, an image never changes. This means you can roll back to any previous version with absolute confidence that you are running exactly the same code, dependencies, and configuration that were tested at that point in time. There is no drift between staging and production.

Environment Parity

Containers encapsulate the entire runtime environment: the operating system libraries, language runtimes, application dependencies, and configuration files. The image tested in CI is identical to the image deployed in production. This drastically reduces environment-specific bugs and makes debugging production issues far simpler—you can pull the exact production image and run it locally.

Speed and Efficiency

Docker's layer caching mechanism, combined with multi-stage builds, allows CI pipelines to rebuild images incrementally. Only changed layers are rebuilt, while unchanged layers are pulled from the cache. This can reduce build times from minutes to seconds, enabling faster feedback loops for developers.

Security and Compliance

Integrating security scanning tools directly into the Docker CI/CD pipeline ensures that every image is scanned for vulnerabilities before it reaches production. You can enforce policies that block deployment if critical CVEs are found, making security a built-in property of the delivery process rather than an afterthought.

Scalable Testing

CI pipelines can spin up dozens of isolated containers simultaneously to run parallel test suites, integration tests against real databases, or end-to-end tests against full microservice topologies. Containers start in seconds and consume minimal resources, making complex test matrices feasible even on modest CI infrastructure.

How to Set Up Docker with CI/CD Pipelines

Writing a Production-Grade Dockerfile

Your Dockerfile is the foundation of the entire pipeline. A production Dockerfile should use multi-stage builds to separate the build environment from the runtime environment, minimizing the final image size and reducing the attack surface.

Here is a complete example for a Node.js application:

# ---- Stage 1: Build dependencies ----
FROM node:20-alpine AS builder

WORKDIR /app

# Copy package files first for better layer caching
COPY package.json package-lock.json ./

# Install ALL dependencies (including devDependencies)
RUN npm ci

# Copy source code
COPY . .

# Build the application (TypeScript compilation, bundling, etc.)
RUN npm run build

# Prune devDependencies after build
RUN npm prune --production

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

# Create a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy only what's needed from the 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

# Expose the application port
EXPOSE 3000

# Use the non-root user
USER appuser

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

# Start the application
CMD ["node", "dist/main.js"]

Multi-Stage Builds for Compiled Languages

For Go, Rust, or Java applications, multi-stage builds are even more impactful because the build tools are heavy but not needed at runtime. Here's a Go example:

# ---- Stage 1: Build the binary ----
FROM golang:1.23-alpine AS builder

WORKDIR /workspace

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# Build a statically-linked binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -ldflags="-w -s" -o /app/server ./cmd/server

# ---- Stage 2: Minimal runtime image ----
FROM scratch

# Copy CA certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

COPY --from=builder /app/server /server

EXPOSE 8080

ENTRYPOINT ["/server"]

The final image contains only the compiled binary—no shell, no package manager, no build tools. This dramatically reduces the attack surface and image size (often under 10 MB).

Configuring the CI/CD Pipeline: GitHub Actions Example

Below is a complete GitHub Actions workflow that builds, tests, scans, and pushes a Docker image. It demonstrates real-world production patterns including layer caching, security scanning, and multi-environment deployments.

name: Docker CI/CD Pipeline

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

env:
  IMAGE_NAME: myapp
  REGISTRY: ghcr.io
  # The full image path will be: ghcr.io/${{ github.repository_owner }}/myapp

jobs:
  # ---- Job 1: Build and Test ----
  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
        with:
          driver-opts: |
            network=host

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

      # Generate metadata: tags, labels, commit SHA
      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=,format=long
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

      # Build the image with layer caching
      - name: Build Docker image
        id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          push: false          # Don't push yet—test first
          load: true           # Load into local Docker for testing
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha,scope=build
          cache-to: type=gha,mode=max,scope=build

      # Run unit tests inside the container
      - name: Run unit tests
        run: |
          docker run --rm \
            --entrypoint npm \
            ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:main \
            run test

      # Run integration tests (requires a database—spin up with Docker Compose)
      - name: Run integration tests
        run: |
          docker compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from app
        env:
          DB_CONNECTION_STRING: ${{ secrets.DB_CONNECTION_STRING }}

  # ---- Job 2: Security Scan ----
  security-scan:
    needs: build-and-test
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Run Trivy vulnerability scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:main
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: 1          # Fail the pipeline on critical/high vulnerabilities
          ignore-unfixed: true

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

  # ---- Job 3: Push and Deploy ----
  push-and-deploy:
    needs: [build-and-test, security-scan]
    if: github.ref == 'refs/heads/main'   # Only deploy from main branch
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        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: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=,format=long
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=raw,value=latest

      # Rebuild with final push (uses cache from earlier build)
      - name: Build and push final image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha,scope=build
          cache-to: type=gha,mode=max,scope=build

      # Deploy to Kubernetes (example using kubectl)
      - name: Deploy to Kubernetes
        run: |
          # Extract the SHA-based tag for pinning
          IMAGE_TAG=$(echo "${{ steps.meta.outputs.tags }}" | grep sha | head -1)
          
          # Set the image in the deployment manifest and apply
          kubectl set image deployment/myapp \
            app=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${IMAGE_TAG} \
            --namespace=production \
            --record
          
          kubectl rollout status deployment/myapp --namespace=production --timeout=5m
        env:
          KUBECONFIG: ${{ secrets.KUBECONFIG }}

GitLab CI Configuration

For teams using GitLab, the pipeline configuration follows a similar pattern. Here's a complete .gitlab-ci.yml file that demonstrates Docker-in-Docker (dind) builds:

stages:
  - build
  - test
  - security
  - deploy

variables:
  IMAGE_NAME: myapp
  REGISTRY: $CI_REGISTRY
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  DOCKER_TLS_CERT_DIR: "/certs"
  DOCKER_HOST: "tcp://docker:2376"
  DOCKER_CERT_PATH: "/certs/client"

# ---- Stage: Build ----
docker-build:
  stage: build
  image: docker:24-dind
  services:
    - docker:24-dind
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
    - docker build -t $IMAGE_TAG .
    - docker push $IMAGE_TAG
  tags:
    - docker
  only:
    - main
    - merge_requests

# ---- Stage: Test ----
unit-tests:
  stage: test
  image: $IMAGE_TAG
  script:
    - npm run test:ci
  needs:
    - docker-build
  artifacts:
    reports:
      junit: test-results.xml

integration-tests:
  stage: test
  image: docker:24-dind
  services:
    - docker:24-dind
    - postgres:16-alpine
  variables:
    POSTGRES_DB: testdb
    POSTGRES_USER: testuser
    POSTGRES_PASSWORD: testpass
  script:
    - docker run --rm --network=host -e DB_HOST=localhost $IMAGE_TAG npm run test:integration
  needs:
    - docker-build

# ---- Stage: Security ----
trivy-scan:
  stage: security
  image: aquasec/trivy:latest
  script:
    - trivy image --severity HIGH,CRITICAL --exit-code 1 --no-progress $IMAGE_TAG
    - trivy image --severity MEDIUM --no-progress $IMAGE_TAG || true  # Medium is non-blocking
  needs:
    - docker-build

# ---- Stage: Deploy ----
deploy-production:
  stage: deploy
  image: alpine/k8s:latest
  script:
    - kubectl config use-context production
    - kubectl set image deployment/myapp app=$IMAGE_TAG --namespace=production
    - kubectl rollout status deployment/myapp --namespace=production --timeout=10m
  environment:
    name: production
    url: https://app.example.com
  needs:
    - unit-tests
    - integration-tests
    - trivy-scan
  only:
    - main
  when: manual    # Optional: require manual approval for production

Docker Compose for Local CI Testing

Before pushing code, developers can run the exact same CI test suite locally using Docker Compose. This file (docker-compose.test.yml) mirrors what the CI pipeline executes:

version: "3.9"

services:
  postgres-test:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"]
      interval: 5s
      timeout: 5s
      retries: 10

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

  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: builder    # Use the builder stage for testing
    depends_on:
      postgres-test:
        condition: service_healthy
      redis-test:
        condition: service_healthy
    environment:
      NODE_ENV: test
      DB_HOST: postgres-test
      DB_PORT: 5432
      DB_USER: testuser
      DB_PASSWORD: testpass
      DB_NAME: testdb
      REDIS_HOST: redis-test
      REDIS_PORT: 6379
    command: npm run test:integration
    volumes:
      - ./coverage:/app/coverage    # Mount coverage reports back to host

Deployment Strategies with Docker

Once the image is in the registry, several deployment strategies are available:

Rolling Updates (Kubernetes)

Kubernetes natively supports rolling updates, gradually replacing old pods with new ones while maintaining service availability. The CI pipeline simply updates the image tag in the deployment, and Kubernetes handles the rest:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: production
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0    # Zero-downtime deployments
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: app
        image: ghcr.io/myorg/myapp:abc123def456   # CI sets this tag
        ports:
        - containerPort: 3000
        readinessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 10
        resources:
          requests:
            cpu: "100m"
            memory: "128Mi"
          limits:
            cpu: "500m"
            memory: "256Mi"

Blue-Green Deployments

For mission-critical applications, blue-green deployments eliminate risk by running two complete environments and switching traffic atomically:

# In CI pipeline, deploy the "green" environment first
kubectl apply -f deployment-green.yaml
kubectl rollout status deployment/myapp-green --namespace=production --timeout=5m

# Run smoke tests against green
curl -f https://green.example.com/health || exit 1

# Switch the service selector to point to green
kubectl patch service myapp-service -p '
{
  "spec": {
    "selector": {
      "app": "myapp",
      "version": "green"
    }
  }
}' --namespace=production

# Scale down blue after confirming green is stable
kubectl scale deployment myapp-blue --replicas=0 --namespace=production

Best Practices for Docker CI/CD in Production

Layer Caching: The Single Biggest Performance Win

Docker image builds can be slow without proper caching. The key principle is to order your Dockerfile instructions so that the most frequently changed files are copied last. Dependencies (which change infrequently) should be installed before application code (which changes on every commit).

In CI, use Docker BuildKit with remote caching backends. GitHub Actions supports type=gha cache, which stores build layers as GitHub Actions cache entries. GitLab CI can use docker build --cache-from with a previously pushed image. The build times often drop from 5+ minutes to under 30 seconds when caching is configured correctly.

Image Tagging Strategy

A disciplined tagging strategy prevents confusion and enables reliable rollbacks. Avoid relying solely on the latest tag in production—it is ambiguous and makes rollbacks impossible. Instead, use a combination of tags:

In production Kubernetes manifests, always pin the exact SHA tag rather than a floating tag like main or latest.

Security Scanning Must Be Blocking

Integrate vulnerability scanners (Trivy, Grype, Snyk) directly into the pipeline and configure them to fail the build on critical or high-severity vulnerabilities. A non-blocking scan that just generates a report is invisible and will be ignored. Make the pipeline enforce your security policy automatically.

# Trivy scan with strict blocking
trivy image \
  --severity HIGH,CRITICAL \
  --exit-code 1 \
  --ignore-unfixed=false \
  ghcr.io/myorg/myapp:abc123def456

# If trivy exits with code 1, the pipeline fails immediately

Never Store Secrets in Images

Docker images are immutable artifacts that may be pulled by anyone with registry access. Never bake secrets (API keys, database passwords, private keys) into a Docker image. Secrets must be injected at runtime via:

If you need secrets during the Docker build phase (for pulling private dependencies, for example), use BuildKit's --secret flag, which mounts secrets temporarily without persisting them in image layers:

# Dockerfile using BuildKit secrets
# syntax=docker/dockerfile:1

FROM node:20-alpine

# Mount a secret for npm private registry access
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
  npm ci

# The .npmrc file is NOT present in the final image

And in CI, pass the secret at build time:

docker buildx build \
  --secret id=npmrc,src=$NPMRC_FILE \
  -t myapp:latest \
  .

Minimize Image Size and Attack Surface

Production images should be as small as possible. Every additional package is a potential vulnerability vector. Key techniques:

Smoke Tests After Deployment

The pipeline should verify that the deployment actually works. After updating the deployment, run a simple smoke test against the production endpoint to confirm the application is responsive. If the smoke test fails, trigger an automatic rollback:

# Smoke test script run in CI after deployment
#!/bin/sh
set -e

ENDPOINT="https://api.example.com/health"

HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$ENDPOINT")

if [ "$HTTP_CODE" != "200" ]; then
  echo "Smoke test failed with HTTP $HTTP_CODE. Rolling back..."
  kubectl rollout undo deployment/myapp --namespace=production
  exit 1
fi

echo "Smoke test passed. Deployment is healthy."

Pipeline as Code and Versioned Build Configurations

All pipeline configurations—Dockerfiles, CI workflow files, deployment manifests—must live in the same version-controlled repository as the application code. This ensures that every commit captures the exact build and deployment logic that was in effect at that time. It also enables peer review of infrastructure changes through the same pull request process as code changes.

Use Docker Layer Squashing for Legacy Applications

For applications with many layers (legacy Dockerfiles with dozens of RUN instructions), consider squashing layers into a single layer to reduce image size and simplify vulnerability scanning. BuildKit supports this with the --squash flag, though use it judiciously as it eliminates caching benefits for squashed layers:

docker buildx build --squash -t myapp:squashed .

Monitor and Alert on Pipeline Failures

A CI/CD pipeline that silently fails is worse than no pipeline at all. Configure notifications (Slack, PagerDuty, email) for pipeline failures, especially in the deploy stage. Every failed deployment should create an alert so the on-call engineer can investigate immediately.

Conclusion

Integrating Docker into your CI/CD pipeline transforms the way you deliver software to production. It replaces ad-hoc build scripts and environment-specific quirks with a single, immutable artifact that flows predictably from a developer's workstation through automated testing and security gates to production deployment. The practices outlined in this guide—multi-stage builds, aggressive layer caching, blocking security scans, immutable SHA-based tagging, secrets isolation, and automated smoke testing with rollback capability—form a production-hardened delivery system that is fast, secure, and auditable. When implemented correctly, Docker-based CI/CD pipelines give teams the confidence to deploy multiple times per day, knowing that every release is the exact same artifact they tested, scanned, and verified.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles