← Back to DevBytes

Docker with CI/CD Pipelines: Best Practices and Common Pitfalls

What Is Docker in CI/CD Pipelines?

Docker in CI/CD pipelines refers to the practice of using containerization throughout your continuous integration and continuous delivery workflow. Instead of relying on bare-metal runners or virtual machines with pre-installed dependencies, every step—from building and testing to packaging and deploying—runs inside isolated, reproducible Docker containers.

At its core, the integration works like this: your CI/CD platform (GitHub Actions, GitLab CI, Jenkins, CircleCI, etc.) spins up a Docker container based on a specified image, executes your build or test commands inside that container, and then often produces a final Docker image that gets pushed to a registry and deployed to production.

The Anatomy of a Docker-Aware Pipeline

A typical Docker-enabled pipeline consists of these stages:

Why Docker Matters in CI/CD

šŸš€ Deploy your AI agent in 10 minutes

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

Try it free →

The marriage of Docker and CI/CD solves several persistent pain points that have plagued software delivery for decades.

Environment Parity

The infamous "it works on my machine" problem disappears. When your CI pipeline runs tests inside the exact same container image that will ship to production, you catch environment-specific bugs early. The OS, system libraries, runtime versions, and configuration are all identical across CI, staging, and production.

Reproducible Builds

Without containers, a CI runner accumulates state over time—installed packages, cached dependencies, leftover artifacts. Two builds of the same commit can produce different results. Docker images are immutable snapshots. Given the same Dockerfile and context, you get the same output, making builds truly reproducible and auditable.

Speed and Parallelism

Containers start in seconds, not minutes. This allows CI/CD platforms to spin up isolated environments for every job in parallel. Matrix builds—testing against multiple language versions, operating systems, or dependency sets—become trivial by simply swapping the base image tag.

Simplified Dependency Management

All build-time and run-time dependencies are declared in a Dockerfile and version-controlled alongside the source code. New contributors can reproduce the entire build pipeline locally with a single docker build command. There's no sprawling wiki page of "how to set up your dev environment."

Immutable Deployment Artifacts

A Docker image is a single, versioned, self-contained artifact that includes everything needed to run the application. Rollbacks become as simple as changing a tag reference. There's no risk that a rollback target server has drifted or lost a critical package update.

How to Use Docker in CI/CD Pipelines

Let's walk through concrete implementations on three popular CI/CD platforms. Each example demonstrates a complete workflow: checkout, build, test, image build, scan, push, and deploy.

Example 1: GitHub Actions

Create a file at .github/workflows/docker-pipeline.yml:

name: Docker CI/CD Pipeline

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

env:
  IMAGE_NAME: ghcr.io/${{ github.repository }}/my-app
  IMAGE_TAG: ${{ github.sha }}

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      
      - name: Run tests in container
        run: |
          docker run --rm \
            --network host \
            -e DATABASE_URL=postgres://postgres:postgres@localhost:5432/test \
            -v ${{ github.workspace }}:/app \
            -w /app \
            node:20-alpine \
            sh -c "npm ci && npm test"
  
  build-and-push:
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      
      - name: Build and push image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
            ${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            BUILDKIT_INLINE_CACHE=1
  
  security-scan:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: 1
  
  deploy:
    needs: security-scan
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy to remote server via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            docker pull ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
            docker stop my-app || true
            docker rm my-app || true
            docker run -d --name my-app \
              --restart unless-stopped \
              -p 80:3000 \
              -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
              ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}

Key takeaways from this GitHub Actions pipeline:

Example 2: GitLab CI

Create a file at .gitlab-ci.yml:

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  DOCKER_LATEST: $CI_REGISTRY_IMAGE:latest
  DOCKER_TLS_CERT_DIR: ""

stages:
  - test
  - build
  - scan
  - deploy

# Use Docker-in-Docker for image building
services:
  - docker:27-dind

before_script:
  - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

test:
  stage: test
  image: node:20-alpine
  script:
    - npm ci
    - npm run test:ci
    - npm run lint
  artifacts:
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

build:
  stage: build
  image: docker:27
  script:
    - docker build
      --cache-from $DOCKER_LATEST
      --build-arg BUILDKIT_INLINE_CACHE=1
      --tag $DOCKER_IMAGE
      --tag $DOCKER_LATEST
      .
    - docker push $DOCKER_IMAGE
    - docker push $DOCKER_LATEST
  only:
    - main
    - tags

container-scanning:
  stage: scan
  image: docker:27
  script:
    - docker run --rm
      -v /var/run/docker.sock:/var/run/docker.sock
      aquasec/trivy
      image --severity HIGH,CRITICAL
      --exit-code 1
      $DOCKER_IMAGE
  only:
    - main

deploy-production:
  stage: deploy
  image: alpine:3
  before_script:
    - apk add --no-cache openssh-client
  script:
    - |
      ssh -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_HOST "
        docker pull $DOCKER_IMAGE &&
        docker stop my-app || true &&
        docker rm my-app || true &&
        docker run -d --name my-app
          --restart unless-stopped
          -p 80:3000
          -e DATABASE_URL='$PROD_DATABASE_URL'
          $DOCKER_IMAGE
      "
  environment: production
  only:
    - main
  when: manual

Critical details for GitLab CI:

Example 3: Jenkins (Declarative Pipeline)

Create a Jenkinsfile at the root of your repository:

pipeline {
    agent none
    
    environment {
        DOCKER_REGISTRY = 'my-registry.example.com'
        IMAGE_NAME      = "${DOCKER_REGISTRY}/my-app"
        IMAGE_TAG       = "${env.BUILD_TAG ?: 'latest'}"
    }
    
    stages {
        stage('Test') {
            agent {
                docker {
                    image 'node:20-alpine'
                    args '-v /tmp/.npm-cache:/root/.npm-cache:rw'
                }
            }
            steps {
                sh '''
                    npm ci --cache /root/.npm-cache
                    npm run test:ci
                    npm run lint
                '''
            }
            post {
                always {
                    junit 'junit.xml'
                }
            }
        }
        
        stage('Build Image') {
            agent { label 'docker-builders' }
            steps {
                script {
                    def dockerfile = 'Dockerfile'
                    def buildArgs = '--build-arg BUILDKIT_INLINE_CACHE=1'
                    
                    sh """
                        docker build \\
                            --cache-from ${IMAGE_NAME}:latest \\
                            ${buildArgs} \\
                            --tag ${IMAGE_NAME}:${IMAGE_TAG} \\
                            --tag ${IMAGE_NAME}:latest \\
                            -f ${dockerfile} .
                    """
                }
            }
        }
        
        stage('Security Scan') {
            agent { label 'docker-builders' }
            steps {
                sh """
                    docker run --rm \\
                        -v /var/run/docker.sock:/var/run/docker.sock \\
                        aquasec/trivy image \\
                        --severity HIGH,CRITICAL \\
                        --exit-code 1 \\
                        ${IMAGE_NAME}:${IMAGE_TAG}
                """
            }
        }
        
        stage('Push to Registry') {
            agent { label 'docker-builders' }
            steps {
                withCredentials([usernamePassword(
                    credentialsId: 'docker-registry-creds',
                    usernameVariable: 'REG_USER',
                    passwordVariable: 'REG_PASS'
                )]) {
                    sh """
                        docker login -u ${REG_USER} -p ${REG_PASS} ${DOCKER_REGISTRY}
                        docker push ${IMAGE_NAME}:${IMAGE_TAG}
                        docker push ${IMAGE_NAME}:latest
                        docker logout ${DOCKER_REGISTRY}
                    """
                }
            }
        }
        
        stage('Deploy') {
            agent { label 'docker-builders' }
            when {
                branch 'main'
            }
            steps {
                script {
                    input message: 'Deploy to production?', ok: 'Proceed'
                }
                withCredentials([
                    sshUserPrivateKey(
                        credentialsId: 'deploy-ssh-key',
                        keyFileVariable: 'SSH_KEY'
                    ),
                    string(credentialsId: 'prod-db-url', variable: 'DB_URL')
                ]) {
                    sh """
                        ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no deploy@prod-server "
                            docker pull ${IMAGE_NAME}:${IMAGE_TAG} &&
                            docker stop my-app || true &&
                            docker rm my-app || true &&
                            docker run -d --name my-app \\
                                --restart unless-stopped \\
                                -p 80:3000 \\
                                -e DATABASE_URL='${DB_URL}' \\
                                ${IMAGE_NAME}:${IMAGE_TAG}
                        "
                    """
                }
            }
        }
    }
    
    post {
        failure {
            notifyBuildStatus('failure')
        }
        success {
            notifyBuildStatus('success')
        }
    }
}

Jenkins-specific considerations:

Best Practices

1. Use Multi-Stage Builds Aggressively

Multi-stage builds separate build-time dependencies from run-time artifacts. Your final image contains only what the application actually needs to run, not compilers, dev headers, or build tools.

# Stage 1: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production runtime
FROM node:20-alpine
RUN apk add --no-cache dumb-init
USER node
WORKDIR /app
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/package.json ./
ENV NODE_ENV=production
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]

Benefits: the final image might be 150MB instead of 900MB, and the attack surface is drastically reduced because there's no compiler, no source code, and no build tools.

2. Optimize Layer Caching with Dockerfile Order

Docker caches layers based on the instruction and the files copied. Structure your Dockerfile so that infrequently changed layers come first.

# Wrong: Copying all source before installing deps
COPY . .
RUN npm ci        # Cache busted on ANY file change

# Correct: Install deps first, then copy source
COPY package*.json ./
RUN npm ci        # Cached unless package.json changes
COPY . .          # Only this layer is rebuilt on source changes

3. Leverage BuildKit for Advanced Caching

Enable BuildKit (set DOCKER_BUILDKIT=1 or use docker buildx) to unlock powerful cache features:

# Mount a cache directory for package managers
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

# Cache Go module downloads
FROM golang:1.22-alpine AS go-builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /app/server .

The --mount=type=cache directive creates a persistent cache volume that survives between builds, even when the layer itself is rebuilt. This is far more efficient than COPY-based caching.

4. Never Commit Secrets to Images

Secrets baked into images are forever. Any developer with access to the registry can extract them. Use build-time secrets and runtime environment variables instead.

# BuildKit secret mounting
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) \
    npm ci --//registry.npmjs.org/:_authToken=$NPM_TOKEN

# In CI, pass the secret:
# docker build --secret id=npm_token,src=.npm_token.txt .

For runtime secrets, inject them at container start via environment variables, mounted files, or a secrets manager—never hardcode them in the Dockerfile.

5. Pin Image Versions Explicitly

Floating tags like node:latest or alpine:3 (without minor version) introduce non-determinism. A build that passes today might fail tomorrow when the base image updates.

# Bad: floating tags
FROM node:alpine

# Good: pinned to digest
FROM node:20.11.1-alpine@sha256:a1b2c3d4e5f6...

# Practical compromise: pin minor version
FROM node:20.11-alpine

6. Use .dockerignore to Minimize Context

The Docker build context (everything sent to the daemon) should be as small as possible. A bloated context slows builds, wastes bandwidth, and can accidentally include secrets.

# .dockerignore
.git
.gitignore
.github
.gitlab-ci.yml
Jenkinsfile
node_modules
npm-debug.log
*.md
.vscode
.idea
.env
.env.*
coverage
dist
*.log
Dockerfile
docker-compose.yml
**/*.test.js
**/*.spec.js

Note that node_modules and dist are ignored from context but will be rebuilt inside the container. The context should contain only source files that the Dockerfile actually references.

7. Tag Images with Multiple Identifiers

A single image should carry multiple tags to support different consumers and workflows:

docker buildx build \
  --tag my-app:latest \
  --tag my-app:1.4.2 \
  --tag my-app:${GIT_SHA} \
  --tag my-app:${GIT_REF_NAME} \
  .

8. Run as Non-Root User

By default, Docker runs processes as root inside the container. This is a security risk—if an attacker escapes the container, they gain root on the host.

FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
WORKDIR /home/appuser/app
COPY --chown=appuser:appgroup . .
CMD ["node", "server.js"]

Many official images now support a built-in user. For example, node images have a node user with uid 1000. Always verify with docker run --rm my-image id that the process isn't root.

9. Implement Health Checks

A Docker health check lets orchestrators (Kubernetes, Swarm, Docker Compose) know if your container is actually functional, not just running.

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1

Without health checks, a container with a hung process can be considered "running" indefinitely while serving zero traffic.

10. Scan Images Before and After Every Build

Vulnerability scanning should happen both during CI (blocking merges) and continuously on stored images (catching newly discovered CVEs in old images).

# In CI pipeline
docker run --rm aquasec/trivy image --severity CRITICAL --exit-code 1 my-image:latest

# Scheduled job (cron) to re-scan all production images
for image in $(docker images --format '{{.Repository}}:{{.Tag}}'); do
  trivy image --severity HIGH,CRITICAL "$image" && echo "PASS: $image" || notify-slack "FAIL: $image"
done

Common Pitfalls

Pitfall 1: Docker-in-Docker Without Proper Privileged Mode

Running Docker inside a Docker container (dind) requires the --privileged flag or specific capabilities. Many teams hit permission errors when their CI platform's security policy blocks privileged containers.

Symptom: docker: Cannot connect to the Docker daemon or permission denied when trying to access /var/run/docker.sock.

Solution A (safer): Use docker buildx with a remote builder or cloud-native builders that don't require local Docker access.

Solution B (if dind is required): Ensure your CI runner allows privileged containers and the dind service is properly configured:

# GitLab CI dind configuration
services:
  - name: docker:27-dind
    alias: docker
    command: ["--tls=false"]
variables:
  DOCKER_HOST: tcp://docker:2375
  DOCKER_TLS_CERT_DIR: ""
  DOCKER_DRIVER: overlay2

Pitfall 2: Cache Misses Due to COPY Ordering

The most common performance pitfall is a Dockerfile where COPY . . appears before RUN npm install. Any change to any file—even a README typo fix—invalidates the entire dependency installation cache.

Fix: Always copy dependency manifests first, install dependencies, then copy source code.

# Correct order
COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/

Pitfall 3: Leaking Secrets in Build Logs or Layers

Build arguments (--build-arg) appear in plain text in image metadata and sometimes in build logs. Environment variables set with ENV are permanently embedded in the image layer.

Don't do this:

ARG AWS_SECRET_ACCESS_KEY
ENV AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
RUN aws s3 cp s3://bucket/file .  # Key is now in image forever

Do this instead:

RUN --mount=type=secret,id=aws_key \
    AWS_SECRET_ACCESS_KEY=$(cat /run/secrets/aws_key) \
    aws s3 cp s3://bucket/file . && \
    unset AWS_SECRET_ACCESS_KEY

Pitfall 4: Overly Large Images Slowing Deployments

Images that include compilers, SDKs, documentation, and test fixtures can exceed 1GB. These take minutes to pull on each deployment node, slowing rollouts and rollbacks.

Diagnosis: Use docker image history my-image and dive my-image to identify bloated layers.

Remediation:

Pitfall 5: Building as Root and Running as Root

Images built without a USER directive run as root. If a vulnerability allows arbitrary code execution, the attacker owns the container and potentially the host.

Fix: Add USER 1000 or a named user to every Dockerfile, and ensure the application doesn't require root privileges. For ports below 1024, use socat, iptables redirection at the infrastructure level, or simply bind to a high port and map it externally.

Pitfall 6: Not Handling Signals Correctly

When a container receives SIGTERM (during docker stop or pod eviction), the process gets 10 seconds to exit gracefully before SIGKILL. If your application ignores signals because it's wrapped in a shell script or npm start, graceful shutdown fails.

Fix: Use an init system like dumb-init or tini, or ensure your application is PID 1 and handles signals properly.

FROM node:20-alpine
RUN apk add --no-cache dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

Pitfall 7: Non-Deterministic Builds

Floating base image tags, unpinned package versions, and timestamps in metadata produce different images from the same Dockerfile. This breaks audit trails and makes debugging production issues harder.

Fix:

Pitfall 8: Docker Socket Mounting vs. Docker-in-Docker

Mounting the host's Docker socket (-v /var/run/docker.sock:/var/run/docker.sock) into a CI container gives that container unrestricted access to the host's Docker daemon. This is convenient but dangerous—the CI job can start, stop, or inspect any container on the host, including other pipelines' jobs.

Recommendation: Prefer Docker-in-Docker (dind) with an isolated daemon, or use a remote builder (like Buildx with a remote driver). If you must mount the socket, ensure the CI runner is single-tenant and ephemeral.

Pitfall 9: Ignoring Image Drift in Long-Running Environments

A deployed image might be secure today, but vulnerabilities are discovered continuously. Images that sit in a registry for months accumulate known CVEs without any visible change.

Mitigation: Schedule regular re-scanning of all production images and implement a policy for automated rebuilds when critical vulnerabilities are found, even if the application code hasn't changed.

Pitfall 10: Hardcoding Environment-Specific Configuration

Baking environment names, URLs, or credentials into images makes them non-portable. An image built for staging can't be promoted to production without rebuilding.

Principle: Build one immutable image, and configure it externally at runtime through environment variables, config files mounted at /etc/config, or a configuration service.

# Good: configuration passed at runtime
docker run -e LOG_LEVEL=debug -e DB_HOST=prod-db.internal my-app:1.0.0

# Bad: environment baked into image
ENV ENVIRONMENT=production
ENV DB_HOST=prod-db.internal

Putting It All Together: A Production-Grade Dockerfile Template

Here's a complete Dockerfile that incorporates multiple best practices discussed above:

# =============================================================================
# Stage 1: Build Stage
# =============================================================================
FROM node:20.11-alpine@sha256:abc123... AS builder

# Create non-root user early
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy dependency manifests first for cache optimization
COPY package.json package-lock.json ./

# Use BuildKit cache mount for npm
RUN --mount=type=cache,target=/root/.npm,uid=1000 \
    npm ci --prefer-offline --no-audit --progress=false

# Copy source code
COPY . .

# Build the application
RUN npm run build && \
    npm prune --production

# =============================================================================
# Stage 2: Production Image
# =============================================================================
FROM node:20.11-alpine@sha256:abc123...

# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init=1.2.5-r2 curl

# Create application user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

USER appuser
WORKDIR /home/appuser/app

# Copy only production artifacts from builder
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 production environment
ENV NODE_ENV=production

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
    CMD curl -f http://localhost:3000/health || exit 1

EXPOSE 3000

ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]

This Dockerfile demonstrates:

Conclusion

šŸš€ 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