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:
- Build a Docker image from a Dockerfile on every push or pull request
- Run unit tests, integration tests, and security scans inside ephemeral containers
- Tag the image with a unique identifier (commit SHA, build number, semantic version)
- Push the image to a container registry (Docker Hub, Amazon ECR, Google Artifact Registry, etc.)
- Deploy the new image to a staging or production environment using orchestration tools like Kubernetes, Docker Compose, or AWS ECS
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:
- Commit SHA tag (
abc123def456): Immutable, unique per build, ideal for production deployments and rollbacks - Semantic version tag (
v2.4.1): Human-readable, mapped to releases - Branch tag (
main): Points to the latest successful build on a branch, useful for staging environments
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:
- Orchestrator secret management (Kubernetes Secrets, Docker Swarm secrets)
- Cloud secret stores (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault)
- Environment variables injected by the deployment platform at container start time
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:
- Use
alpineordistrolessbase images instead of full Debian/Ubuntu images - Employ multi-stage builds to exclude build tools from the final image
- Remove package manager caches (
apt-get clean && rm -rf /var/lib/apt/lists/*) - Run as a non-root user (create a dedicated user in the Dockerfile)
- Set filesystem to read-only where possible (
readOnlyRootFilesystem: truein Kubernetes)
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.