Why Docker still powers DevOps automation in 2024
Containers remain the lingua franca of modern CI/CD. In 2024, Docker is more than a way to run apps—it’s a backbone for predictable builds, fast feedback, and safe deployments across architectures and clouds. With BuildKit now default, Docker Compose v2 fully integrated, and first-class SBOM/provenance support, teams can automate end-to-end software delivery with fewer moving parts and more confidence.
This guide distills DevOps automation with Docker into two things you can use today:
- Five essential Docker commands that underpin efficient CI/CD.
- Proven deployment configurations you can copy, tweak, and ship.
Expect practical examples, small but meaningful optimizations, and actionable advice you can roll into your pipeline this sprint.
Before you start: the baseline we’ll use
- Docker Engine/CLI v24+ (BuildKit enabled by default)
- Docker Compose v2 (invoked as docker compose)
- A container registry (e.g., GHCR, ECR, GCR, Docker Hub)
- A sample service (we’ll show Node.js, but patterns apply broadly)
- GitHub Actions/GitLab/Jenkins snippets for CI/CD
Note: All examples are designed to be production-oriented—multi-stage builds, non-root users, healthchecks, and caching to keep pipelines fast.
The 5 essential Docker commands for CI/CD
1) docker buildx build — reproducible, multi-platform, cache-friendly builds
Buildx is the modern way to build images. It enables:
- Multi-stage builds to keep images lean
- Cross-platform images (linux/amd64, linux/arm64)
- Layer caching between CI runs
- SBOM/provenance for supply-chain transparency
A production-grade Dockerfile for a Node.js API might look like:
# syntax=docker/dockerfile:1.6
ARG NODE_VERSION=20-alpine
FROM node:${NODE_VERSION} AS base
WORKDIR /app
# Create non-root user
RUN addgroup -g 10001 -S app && adduser -S -D -H -u 10001 app app
# Leverage caching for faster installs
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts
# Build stage
FROM base AS build
COPY . .
# If you need private deps during build:
# RUN --mount=type=secret,id=npm_token \
# bash -lc 'echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > .npmrc && npm ci --ignore-scripts'
RUN npm run build
# Optional: run tests in a build stage so CI fails fast
FROM build AS test
RUN npm test
# Final runtime image
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
# Security hardening
USER 10001
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD node -e "http=require('http');http.get('http://localhost:3000/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
CMD ["node", "dist/server.js"]
Build and push with cache, multi-arch, and SBOM:
# Create a builder if you don’t have one
docker buildx create --use
# Build test stage to run tests during the build
docker buildx build \
--target test \
--progress=plain \
--cache-from=type=registry,ref=ghcr.io/acme/api:buildcache \
--cache-to=type=registry,ref=ghcr.io/acme/api:buildcache,mode=max \
--sbom=true --provenance=true \
-t ghcr.io/acme/api:test .
# Build, multi-arch, and push the final image
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-from=type=registry,ref=ghcr.io/acme/api:buildcache \
--cache-to=type=registry,ref=ghcr.io/acme/api:buildcache,mode=max \
--sbom=true --provenance=true \
-t ghcr.io/acme/api:1.0.0 \
-t ghcr.io/acme/api:latest \
--push .
Tips:
- Use --build-arg for environment-driven builds (e.g., ARG GIT_SHA).
- Keep the number of layers minimal and files copied explicit.
- Use secrets at build time for private modules; never bake secrets into images.
2) docker run — fast local tests and ad-hoc validation
Run containers locally in the same way they’ll run in CI or production:
- Spin up ephemeral services for integration tests.
- Mount test artifacts or configs.
- Set environment variables consistently.
Example: start Postgres for integration tests and run the app’s test suite against it.
# Start a disposable Postgres container
docker run --rm -d --name testdb \
-e POSTGRES_DB=app -e POSTGRES_USER=app -e POSTGRES_PASSWORD=secret \
-p 5432:5432 \
postgres:16-alpine
# Wait until Postgres is healthy (simplified)
until docker exec testdb pg_isready -U app >/dev/null 2>&1; do sleep 1; done
# Run the app’s tests in a container connected to testdb
docker run --rm --name app-tests \
--env DATABASE_URL=postgres://app:[email protected]:5432/app \
ghcr.io/acme/api:test
Handy run flags:
- --rm: clean up automatically
- -e: set environment variables
- -v: mount volumes for local dev
- --network: connect to a shared network for multi-service tests
- --pull=always: ensure you’re testing the latest image
- --add-host: add host entries for service discovery
Use docker exec to debug running containers:
docker exec -it app-tests sh
3) docker compose — local orchestration and preview envs
Compose is the simplest way to orchestrate dependencies on a laptop, in CI, or on a small VM. It’s perfect for:
- Local dev stacks (app + db + cache + message broker)
- Ephemeral environments for feature branches
- Lightweight production deployments on a single host
A production-minded Compose file:
# docker-compose.yml (Compose Spec v3.9+)
version: "3.9"
name: acme-api
services:
app:
build:
context: .
target: runner
image: ghcr.io/acme/api:${GIT_SHA:-dev}
env_file:
- .env
ports:
- "8080:3000"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 3s
retries: 3
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.0"
memory: "512M" # Note: deploy.resources is not enforced by Compose on standalone Docker
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 3s
retries: 10
restart: unless-stopped
cache:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
restart: unless-stopped
profiles: ["dev", "test"]
volumes:
db_data:
redis_data:
Common Compose workflows:
- Start everything: docker compose up -d
- Tail logs: docker compose logs -f app
- Recreate with new image: docker compose pull app && docker compose up -d app
- Use profiles for extra services: docker compose --profile dev up -d
For production, pair Compose with a reverse proxy (e.g., Traefik or Nginx) and an automated reload strategy. For zero/minimal downtime, pre-pull the new image, then up -d the service. Compose will restart the container, and the healthcheck ensures traffic only flows when ready.
4) docker login, tag, push, pull — robust registry operations
Registry hygiene is everything in CI/CD. You’ll authenticate, push, and pull constantly.
- docker login: authenticate with your registry
- docker tag: normalize naming (e.g., repo/image:tag)
- docker push: publish artifacts
- docker pull: consume artifacts
Example:
# Login securely (use CI secret variables)
echo "$CR_PAT" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
# Tag with version and commit SHA
docker tag ghcr.io/acme/api:latest ghcr.io/acme/api:1.0.0
docker tag ghcr.io/acme/api:latest ghcr.io/acme/api:sha-abcdef1
# Push all relevant tags
docker push ghcr.io/acme/api:1.0.0
docker push ghcr.io/acme/api:sha-abcdef1
docker push ghcr.io/acme/api:latest
# Consumers pull explicitly-pinned tags for predictability
docker pull ghcr.io/acme/api:1.0.0
Actionable advice:
- Use immutable tags (git SHAs) for deployments; reserve latest for humans.
- Maintain a retention policy and prune old tags to control costs.
- Attach SBOMs and provenance (Buildx adds these automatically with --sbom, --provenance).
- If using AWS ECR, prefer OIDC over static credentials for better security posture.
5) docker logs, inspect, and prune — troubleshoot fast and keep runners clean
A happy pipeline is a clean runner. Logs and metadata drive quick fixes; pruning prevents flakes and disk-full failures.
- docker logs: immediate visibility into runtime issues
- docker inspect: deep metadata (env vars, mounts, health, config)
- docker system prune / docker buildx prune: reclaim disk space
Examples:
# Debugging a failing container
docker logs -n 200 app
docker inspect app | jq '.[0].State.Health'
# Cleanup on CI runners (use with care)
docker system prune -af --volumes
docker buildx prune -af --filter until=24h
Tips:
- Prune at the end of CI jobs for ephemeral runners; be more conservative on shared agents.
- Use healthchecks for deterministic rollouts and for CI to wait on dependencies gracefully.
- Consider JSON-file log driver with rotation (or ship logs to a central sink).
Deployment configurations that work in 2024
Multi-stage Dockerfile best practices (recap + extras)
- Use base/build/runner separation to minimize runtime image size.
- Don’t ship tools you only need at build time.
- Run as a non-root user; set a HEALTHCHECK.
- Parameterize via ARG/ENV for flexibility.
- Prefer distroless or minimal base images when possible for smaller attack surface.
- Keep Dockerfile close to source to minimize context, or use .dockerignore aggressively.
A minimal .dockerignore:
node_modules
.git
dist
coverage
*.log
.DS_Store
If you use Go, Rust, or Java, the same principles apply:
- Build in one stage, copy only the binary or JAR/wars into a slim runtime base.
- For Go, set CGO_ENABLED=0 when possible and run on scratch/distroless.
Production with Docker Compose on a single VM
For many teams, a simple VM with Docker Compose is perfectly fine (and cost-effective) for staging or smaller production workloads. Add a reverse proxy and TLS via Let’s Encrypt.
A lean Traefik + app Compose setup:
# docker-compose.prod.yml
version: "3.9"
services:
traefik:
image: traefik:v3.0
command:
- "--providers.docker=true"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "[email protected]"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_data:/letsencrypt
restart: unless-stopped
app:
image: ghcr.io/acme/api:${IMAGE_TAG:-latest}
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`api.example.com`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
depends_on:
traefik:
condition: service_started
restart: unless-stopped
volumes:
traefik_data:
Zero/low downtime deploys:
- Pull image first: docker compose -f docker-compose.prod.yml pull app
- Recreate: docker compose -f docker-compose.prod.yml up -d app
- Traefik routes traffic to the new container once healthy.
GitHub Actions: build, test, scan, push, and deploy via SSH
A complete workflow using cache, SBOM/provenance, and remote Docker context:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [ main ]
pull_request:
jobs:
build-test-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Derive tags
id: meta
run: |
SHA="${{ github.sha }}"
echo "TAG_SHA=sha-${SHA::7}" >> $GITHUB_OUTPUT
echo "TAG_VERSION=1.0.${{ github.run_number }}" >> $GITHUB_OUTPUT
- name: Build test stage (runs tests)
uses: docker/build-push-action@v6
with:
context: .
target: test
push: false
platforms: linux/amd64
cache-from: type=registry,ref=ghcr.io/acme/api:buildcache
cache-to: type=registry,ref=ghcr.io/acme/api:buildcache,mode=max
provenance: true
sbom: true
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/acme/api:${{ steps.meta.outputs.TAG_VERSION }}
ghcr.io/acme/api:${{ steps.meta.outputs.TAG_SHA }}
ghcr.io/acme/api:latest
cache-from: type=registry,ref=ghcr.io/acme/api:buildcache
cache-to: type=registry,ref=ghcr.io/acme/api:buildcache,mode=max
provenance: true
sbom: true
- name: Optional security snapshot (Docker Scout or Trivy)
run: |
docker pull ghcr.io/acme/api:${{ steps.meta.outputs.TAG_VERSION }}
docker scout cves ghcr.io/acme/api:${{ steps.meta.outputs.TAG_VERSION }} || true
deploy:
needs: build-test-push
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Configure SSH
uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.PROD_SSH_KEY }}
- name: Create remote Docker context
run: |
docker context create prod --docker "host=ssh://[email protected]" || true
- name: Deploy with Compose remotely
run: |
docker --context prod compose -f docker-compose.prod.yml pull app
IMAGE_TAG=${{ needs.build-test-push.outputs.tag || 'latest' }}
docker --context prod compose -f docker-compose.prod.yml up -d app
Notes:
- Build once, deploy the exact tag; avoid rebuilding on the server.
- Use OIDC to cloud registries for passwordless auth where possible.
- For blue/green, deploy app-blue and app-green with different tags, then switch Traefik routing via label changes.
GitLab CI: Docker-in-Docker with BuildKit
GitLab CI example building and pushing to the GitLab Container Registry:
# .gitlab-ci.yml
stages: [build, deploy]
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ""
IMAGE: $CI_REGISTRY_IMAGE
TAG_SHA: "sha-$CI_COMMIT_SHORT_SHA"
build:
stage: build
image: docker:24.0
services:
- docker:24.0-dind
script:
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
- docker buildx create --use
- docker buildx build --target test --progress=plain .
- docker buildx build --platform linux/amd64,linux/arm64 \
-t $IMAGE:$TAG_SHA -t $IMAGE:latest \
--push --sbom=true --provenance=true \
--cache-from=type=registry,ref=$IMAGE:buildcache \
--cache-to=type=registry,ref=$IMAGE:buildcache,mode=max .
deploy:
stage: deploy
image: alpine:3.20
script:
- apk add --no-cache openssh-client
- ssh -o StrictHostKeyChecking=no [email protected] "docker login $CI_REGISTRY -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD && docker pull $IMAGE:$TAG_SHA && IMAGE_TAG=$TAG_SHA docker compose -f /srv/app/docker-compose.prod.yml up -d app"
only:
- main
Jenkins: a minimal declarative pipeline
pipeline {
agent any
environment {
REGISTRY = "ghcr.io"
IMAGE = "ghcr.io/acme/api"
TAG = "sha-${env.GIT_COMMIT.take(7)}"
}
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Build & Test') {
steps {
sh 'docker buildx create --use || true'
sh 'docker buildx build --target test --progress=plain .'
}
}
stage('Push') {
steps {
withCredentials([string(credentialsId: 'GHCR_TOKEN', variable: 'TOKEN')]) {
sh 'echo $TOKEN | docker login ghcr.io -u USERNAME --password-stdin'
}
sh """
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ${IMAGE}:${TAG} -t ${IMAGE}:latest \
--push --sbom=true --provenance=true \
--cache-from=type=registry,ref=${IMAGE}:buildcache \
--cache-to=type=registry,ref=${IMAGE}:buildcache,mode=max .
"""
}
}
stage('Deploy') {
steps {
sh 'ssh -o StrictHostKeyChecking=no [email protected] "docker pull ${IMAGE}:${TAG} && IMAGE_TAG=${TAG} docker compose -f /srv/app/docker-compose.prod.yml up -d app"'
}
}
}
post {
always {
sh 'docker system prune -af || true'
sh 'docker buildx prune -af || true'
}
}
}
Zero-downtime strategies with Docker
- Healthchecks: ensure the new container is “ready” before traffic arrives. Most reverse proxies respect 200 OK for readiness routes (/health).
- Blue/Green with tags: run app-blue with ghcr.io/acme/api:sha-abc123 and app-green with sha-def456, then switch routing via Traefik labels or DNS.
- Rolling restarts: for simple setups with Compose, pre-pull images and restart containers one by one. For larger fleets, use orchestrators (Swarm or Kubernetes) for rolling updates.
- Database migrations: run migrations as a separate job before flipping traffic; use docker run --rm to execute the migration container explicitly and idempotently.
Example migration run:
docker run --rm \
-e DATABASE_URL="$DATABASE_URL" \
ghcr.io/acme/api:${TAG} \
node dist/scripts/migrate.js
Security and compliance essentials in CI/CD
- Scan images on push: use Docker Scout or Trivy in CI.
- Pin base images to digests to avoid surprise changes: FROM node@sha256:...
- Don’t run as root; drop Linux capabilities you don’t need.
- Generate SBOM and provenance: --sbom=true --provenance=true with Buildx.
- Keep secrets out of images: use build secrets for private registries and runtime secrets from a manager (e.g., AWS Secrets Manager, Vault).
Trivy quick scan:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.50.2 image --severity HIGH,CRITICAL ghcr.io/acme/api:latest
Common pitfalls and how to avoid them
- “Works on my machine”: Use docker compose for local parity with CI and production. Bake tests into the image or run them in containers.
- Bloated images: Multi-stage builds and minimal bases reduce attack surface and pull times.
- Flaky CI due to disk space: Always prune on ephemeral runners; set cache TTLs.
- Non-deterministic deployments: Use immutable tags and pin base images to digests.
- Secrets leakage: Never bake environment secrets into images; audit Dockerfiles for ADD/COPY mistakes.
Putting it all together: a repeatable delivery flow
- Developer merges to main.
- CI runs docker buildx build --target test to execute tests during the build.
- CI builds and pushes a multi-arch image with immutable and human-friendly tags, plus SBOM and provenance.
- Optional: CI scans the pushed image for vulnerabilities and fails on critical issues.
- Deploy job connects to a remote Docker context or host via SSH and updates the service with docker compose up -d after pulling the new tag.
- Healthchecks ensure the instance becomes live before traffic flows; logs and inspect help troubleshoot if something goes wrong.
- CI prunes caches and artifacts to keep runners healthy and fast.
This pattern gives you speed, safety, and clarity—exactly what DevOps automation with Docker should deliver in 2024.
Quick reference: the five commands
- docker buildx build: reliable, cache-aware, multi-platform image builds with SBOM/provenance.
- docker run: run containers for tests, ad-hoc validation, and local integration checks.
- docker compose: orchestrate services locally and in small-scale deployments.
- docker login/tag/push/pull: publish and consume immutable artifacts from a registry.
- docker logs/inspect/prune: troubleshoot quickly and keep environments clean.
Adopt these commands and the deployment patterns above, and your CI/CD will be faster, more predictable, and easier to operate—today and as your platform scales.