Learn

/

CI/CD Pipelines

CI/CD Pipelines

4 patterns

Building images in CI, layer caching in pipelines, multi-platform builds, and registry tagging. You'll hit this when your CI pipeline rebuilds everything from scratch on every commit.

Avoid
# GitHub Actions
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t myapp:latest .
      # Builds from scratch every time
      # No layer caching
# GitHub Actions
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t myapp:latest .
      # Builds from scratch every time
      # No layer caching

Prefer
# GitHub Actions
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: myapp:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
# GitHub Actions
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: myapp:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
Why avoid

Without layer caching, every CI run builds every layer from scratch. Installing dependencies, compiling code, and copying assets all run again even if nothing changed. This wastes time and compute resources on every commit.

Why prefer

BuildKit's GitHub Actions cache backend (type=gha) stores and retrieves Docker layers between CI runs. Unchanged layers are reused, dramatically reducing build times. mode=max caches all layers, not just the final image.

Docker docs: GitHub Actions cache
Avoid
# Build and push with mutable tag
docker build -t registry.io/myapp:latest .
docker push registry.io/myapp:latest

# Deploy
kubectl set image deployment/web \
  web=registry.io/myapp:latest
# Build and push with mutable tag
docker build -t registry.io/myapp:latest .
docker push registry.io/myapp:latest

# Deploy
kubectl set image deployment/web \
  web=registry.io/myapp:latest

Prefer
# Build and push with immutable tag
SHA=$(git rev-parse --short HEAD)
docker build -t registry.io/myapp:$SHA .
docker push registry.io/myapp:$SHA

# Deploy with specific version
kubectl set image deployment/web \
  web=registry.io/myapp:$SHA
# Build and push with immutable tag
SHA=$(git rev-parse --short HEAD)
docker build -t registry.io/myapp:$SHA .
docker push registry.io/myapp:$SHA

# Deploy with specific version
kubectl set image deployment/web \
  web=registry.io/myapp:$SHA
Why avoid

latest is a mutable tag that gets overwritten on every push. You can't tell which version is running without inspecting the image digest. Rollbacks to latest deploy whatever was last pushed, not a specific known-good version. Different environments pulling latest at different times get different code.

Why prefer

Tagging with the commit SHA creates an immutable, traceable image. You can always determine which code is running in any environment. Rollbacks point to a specific previous SHA. Two environments running the same SHA are guaranteed to have identical code.

Docker docs: Manage tags
Avoid
# Only builds for CI runner's arch
docker build -t myapp:1.0 .
docker push registry.io/myapp:1.0

# Fails on ARM servers or
# Apple Silicon dev machines
# Only builds for CI runner's arch
docker build -t myapp:1.0 .
docker push registry.io/myapp:1.0

# Fails on ARM servers or
# Apple Silicon dev machines

Prefer
# Build for multiple architectures
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag registry.io/myapp:1.0 \
  --push .

# Works on x86 servers, ARM servers,
# and Apple Silicon dev machines
# Build for multiple architectures
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag registry.io/myapp:1.0 \
  --push .

# Works on x86 servers, ARM servers,
# and Apple Silicon dev machines
Why avoid

Building without --platform produces an image only for the CI runner's architecture (usually amd64). Deploying this on ARM servers causes exec format errors. Developers on Apple Silicon Macs run the image through slow emulation.

Why prefer

docker buildx build --platform creates a multi-architecture manifest. Docker automatically pulls the right image for the host architecture. Your image works on x86 CI runners, ARM-based cloud instances (Graviton, Ampere), and Apple Silicon Macs.

Docker docs: Multi-platform builds
Avoid
# Build, push, deploy
# No security check
docker build -t myapp:$SHA .
docker push registry.io/myapp:$SHA
kubectl set image deployment/web \
  web=registry.io/myapp:$SHA
# Build, push, deploy
# No security check
docker build -t myapp:$SHA .
docker push registry.io/myapp:$SHA
kubectl set image deployment/web \
  web=registry.io/myapp:$SHA

Prefer
# Build, scan, push, deploy
docker build -t myapp:$SHA .

# Scan for vulnerabilities
docker scout cves myapp:$SHA \
  --exit-code \
  --only-severity critical,high

# Only push and deploy if scan passes
docker push registry.io/myapp:$SHA
kubectl set image deployment/web \
  web=registry.io/myapp:$SHA
# Build, scan, push, deploy
docker build -t myapp:$SHA .

# Scan for vulnerabilities
docker scout cves myapp:$SHA \
  --exit-code \
  --only-severity critical,high

# Only push and deploy if scan passes
docker push registry.io/myapp:$SHA
kubectl set image deployment/web \
  web=registry.io/myapp:$SHA
Why avoid

Deploying without scanning means known CVEs in your base image or dependencies go straight to production. By the time a periodic scan catches them, the vulnerable image has been serving traffic for hours or days.

Why prefer

Scanning images before pushing to a registry catches known vulnerabilities in base images and dependencies. --exit-code makes the scan fail the pipeline on critical/high findings. Vulnerable images never reach production.

Docker docs: Docker Scout