Skip to content
Codeloom
CI/CD

Optimize Docker Builds in CI/CD Pipelines

Speed up Docker builds in CI/CD — multi-stage builds, layer caching, BuildKit, registry caching, and slim base images for faster deployments.

·5 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • Why Docker builds are slow in CI and how to fix it
  • Layer ordering and caching strategies
  • BuildKit cache mounts and registry caching
  • Multi-stage builds for smaller production images
  • GitHub Actions and GitLab CI Docker caching

Prerequisites

  • Basic Docker knowledge
  • A CI/CD pipeline that builds Docker images

Docker builds in CI are often painfully slow because CI runners start fresh — no local layer cache, no previously built images. A build that takes 30 seconds locally can take 10 minutes in CI. Here is how to fix that.

Why CI Docker Builds Are Slow

Locally, Docker caches every layer. Change one line of code and only the layers after that line rebuild. In CI, the runner has no cache. Every RUN instruction executes from scratch, every apt-get install re-downloads packages, every npm install re-fetches every dependency.

The fix involves two strategies: ordering layers so the most stable ones come first and persisting the cache between CI runs.

Order Layers by Change Frequency

Docker caches layers sequentially. Once a layer changes, every layer after it rebuilds. Put the things that change least at the top:

FROM node:22-slim

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

package.json changes rarely compared to source code. By copying it first and running npm ci before copying the rest, the dependency installation layer stays cached across most builds.

A common mistake is COPY . . before RUN npm ci. This invalidates the dependency cache on every source code change.

Use Multi-Stage Builds

Multi-stage builds separate the build environment from the runtime environment. Your production image only contains what it needs to run:

FROM node:22-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]

The builder stage has all dev dependencies and build tools. The final image only has the compiled output. This often cuts image size by 50-80%.

Enable BuildKit

BuildKit is Docker’s modern build engine. It builds stages in parallel, has better caching, and supports cache mounts. Enable it in CI:

env:
  DOCKER_BUILDKIT: 1

Or use docker buildx build which uses BuildKit by default.

Cache Mounts for Package Managers

BuildKit cache mounts persist package manager caches across builds without baking them into the image layer:

FROM python:3.13-slim

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
FROM node:22-slim

RUN --mount=type=cache,target=/root/.npm \
    npm ci

The cached packages survive layer invalidation. Even if requirements.txt changes, pip only downloads the new or updated packages.

Registry-Based Caching

The most effective CI caching strategy is pushing cache layers to a registry:

- uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/myorg/myapp:latest
    cache-from: type=registry,ref=ghcr.io/myorg/myapp:cache
    cache-to: type=registry,ref=ghcr.io/myorg/myapp:cache,mode=max

mode=max caches all layers including intermediate build stages, not just the final image layers. This is the single biggest speedup for CI Docker builds.

GitHub Actions Cache Backend

GitHub Actions has a native cache backend for BuildKit:

- uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/myorg/myapp:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

This uses GitHub’s cache storage (shared across workflows in the same repository) instead of a registry. It is simpler to set up and works well for most projects.

Choose Slim Base Images

Base image size directly affects build and push times:

Base ImageSize
node:22~350 MB
node:22-slim~80 MB
node:22-alpine~50 MB
gcr.io/distroless/nodejs22~40 MB

Alpine is smallest but uses musl libc, which can cause compatibility issues with native Node modules. slim variants use Debian with unnecessary packages removed — usually the best tradeoff.

For compiled languages like Go and Rust, use scratch or distroless as the final stage since the binary has no runtime dependencies.

Minimize Layers

Each RUN instruction creates a layer. Combine related commands:

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      curl \
      ca-certificates && \
    rm -rf /var/lib/apt/lists/*

Cleaning up in the same RUN instruction actually reduces the layer size. If you rm in a separate RUN, the files still exist in the previous layer.

Use .dockerignore

Without a .dockerignore, Docker sends everything in the build context to the daemon — including node_modules, .git, test fixtures, and documentation:

.git
node_modules
dist
*.md
.env*
coverage
.github

A large build context slows down every build, even cached ones, because the context transfer happens before any caching logic.

Parallel Multi-Platform Builds

Building for multiple architectures (amd64 and arm64) in CI:

- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
  with:
    context: .
    platforms: linux/amd64,linux/arm64
    push: true
    tags: ghcr.io/myorg/myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

BuildKit builds both platforms in parallel when possible. QEMU handles cross-architecture emulation for RUN instructions.

Measure Build Performance

Add timing to your builds to identify bottlenecks:

DOCKER_BUILDKIT=1 docker build --progress=plain . 2>&1 | tee build.log

--progress=plain shows the time each step takes. Look for the slowest RUN instructions and optimize those first.

Wrapping Up

CI Docker builds are slow because there is no local cache. Fix this with three changes: order your Dockerfile layers by change frequency, use BuildKit with registry or GitHub Actions caching, and keep your images slim with multi-stage builds. A well-optimized Docker build in CI should take under two minutes, not ten.