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

  1. Developer merges to main.
  2. CI runs docker buildx build --target test to execute tests during the build.
  3. CI builds and pushes a multi-arch image with immutable and human-friendly tags, plus SBOM and provenance.
  4. Optional: CI scans the pushed image for vulnerabilities and fails on critical issues.
  5. 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.
  6. Healthchecks ensure the instance becomes live before traffic flows; logs and inspect help troubleshoot if something goes wrong.
  7. 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.

Share this code profile
Last updated: Oct 07, 2025