Docker Multi-Stage Builds: Shrink Your Images and Speed Up CI


Executive Snapshot

Category Recommendation
Primary Use Case Separate build tooling from runtime artifacts
Image Size Reduction Typically 70–95% smaller than single-stage builds
CI Speed Gain Faster layer caching reduces rebuild time by 30–60%
Minimum Docker Version 17.05 (multi-stage GA); 23.0+ recommended for BuildKit
Best Languages Go, Java, Node.js, Python, .NET, Rust
Key Risk Missing runtime dependencies in the final stage
Toolchain Requirement BuildKit enabled (default in Docker Desktop 4.x+, Engine 23+)

TL;DR

Flowchart of a Docker multi-stage build: source code and Dockerfile flow through a build stage (install SDK, compile, produce artifacts) into a runtime base image that copies only the artifacts, yielding a small hardened final image.
Build stage compiles, runtime stage copies only artifacts into a small image.
  • A docker multi-stage build uses multiple FROM instructions in one Dockerfile, letting you compile in a fat builder image and copy only the finished artifact into a minimal runtime image.
  • Image sizes routinely drop from 1–2 GB to under 50 MB by leaving compilers, test frameworks, and package caches behind.
  • Named stages (AS builder) and selective COPY --from instructions are the core syntax — everything else is standard Dockerfile.
  • BuildKit's parallel stage execution and aggressive layer caching make multi-stage builds a force-multiplier for CI pipelines.
  • The pattern applies universally — compiled binaries (Go, Rust), JVM artifacts (Java, Kotlin), transpiled assets (Node/TypeScript), and even Python virtualenvs all benefit.

Introduction

Ship a Node.js app without multi-stage builds and your production image drags along npm, the TypeScript compiler, every devDependency, and the full Debian toolchain — easily 1.5 GB of dead weight that never executes in production. That bloat isn't just a storage bill; it's a larger attack surface, slower registry pushes, and longer Kubernetes pod start times.

Docker introduced multi-stage builds in version 17.05 to solve exactly this problem. The idea is deceptively simple: use multiple FROM blocks in one Dockerfile, do your heavy lifting in early stages, then COPY only the output you need into a lean final stage. The intermediate layers never reach your registry.

The result is images that are faster to push, pull, and scan — and CI pipelines where unchanged stages hit the cache and skip rebuilding entirely. This tutorial walks you through the full pattern: syntax, real-world examples for three language ecosystems, BuildKit caching strategies, and the pitfalls that catch engineers the first time they try it.


Prerequisites

  • Docker Engine 20.10+ installed (Engine 23+ for full BuildKit features)
  • BuildKit enabled — set DOCKER_BUILDKIT=1 or use docker buildx build
  • Basic familiarity with writing single-stage Dockerfiles
  • A working understanding of your language's build toolchain (compiler, package manager, test runner)
  • Optional: a CI system (GitHub Actions, GitLab CI, Jenkins) to apply the caching patterns

1. How Docker Multi-Stage Builds Work

1.1 The Core Mechanic

Every FROM statement opens a new build stage. Stages are isolated — each gets its own filesystem. You move files between them with COPY --from=<stage>. When the build finishes, Docker discards every layer from every stage except the final one (unless you explicitly target an intermediate stage).

Flowchart of a Node.js multi-stage build with three stages — deps (node:20-alpine, npm ci), builder (copy source, npm run build), and runner (copy dist, CMD node dist/index.js) — where only the runner image is shipped.
A Node.js multi-stage build: the deps and builder stages stay behind — only the lean runner image ships.

The green runner stage is the only one pushed to your registry.

1.2 Key Syntax Reference

# Name a stage with AS
FROM node:20-alpine AS deps

# Reference a named stage
COPY --from=deps /app/node_modules ./node_modules

# Reference a stage by index (fragile — prefer names)
COPY --from=0 /app/dist ./dist

# Pull from an external image directly
COPY --from=alpine:3.19 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

2. Real-World Example: Go Binary

Go is the canonical poster child for multi-stage builds. The Go toolchain is ~600 MB; the compiled static binary can sit in a scratch image with zero OS overhead.

# syntax=docker/dockerfile:1.7
# ── Stage 1: Build ────────────────────────────────────────────────────────────
FROM golang:1.22-alpine AS builder

WORKDIR /src

# Cache dependency downloads as a separate layer
COPY go.mod go.sum ./
RUN go mod download

# Copy source and compile a fully static binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w" \
    -trimpath \
    -o /out/api ./cmd/api

# ── Stage 2: Test (optional, runs in CI target) ────────────────────────────────
FROM builder AS test
RUN go test -race ./...

# ── Stage 3: Final runtime image ───────────────────────────────────────────────
FROM scratch AS runtime

# Copy TLS certificates from a known-good image
COPY --from=alpine:3.19 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /out/api /api

USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/api"]

Size comparison:

Image Size
golang:1.22-alpine (single-stage) ~620 MB
Multi-stage scratch final ~8 MB

Build for production:

# Standard build — produces the 'runtime' stage
docker build --target runtime -t myapp:latest .

# Run tests in CI without producing a final image
docker build --target test -t myapp:test .
docker run --rm myapp:test

3. Real-World Example: Node.js / TypeScript

Node adds complexity because the runtime needs node_modules, but only the production subset — and the TypeScript compiler should never ship.

# syntax=docker/dockerfile:1.7
# ── Stage 1: Install ALL dependencies ─────────────────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# ── Stage 2: Build TypeScript ─────────────────────────────────────────────────
FROM deps AS builder
COPY tsconfig.json ./
COPY src ./src
RUN npm run build          # outputs to /app/dist

# ── Stage 3: Install PRODUCTION deps only ─────────────────────────────────────
FROM node:20-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# ── Stage 4: Lean runtime ─────────────────────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app

# Non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder   /app/dist         ./dist

ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]

Why a separate prod-deps stage? It runs in parallel with builder under BuildKit, shaving wall-clock time off your CI pipeline. Both stages only re-run when package-lock.json changes.


4. Real-World Example: Python

Python doesn't compile to a binary, but multi-stage builds still eliminate build tools (gcc, python3-dev, header files) needed to compile C-extension wheels.

# syntax=docker/dockerfile:1.7
# ── Stage 1: Build wheels ─────────────────────────────────────────────────────
FROM python:3.12-slim AS builder

RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libpq-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .

# Build wheels into a local directory
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

# ── Stage 2: Lean runtime ─────────────────────────────────────────────────────
FROM python:3.12-slim AS runtime

WORKDIR /app

# Install pre-built wheels — no compiler needed
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/*.whl \
    && rm -rf /wheels

COPY src ./src

RUN useradd -m -u 1001 appuser
USER appuser

ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

5. BuildKit Caching Strategies for CI

Enabling BuildKit is the single highest-leverage change you can make for CI performance. Here is how to configure it correctly.

5.1 Inline Cache (Simple, single-machine)

export DOCKER_BUILDKIT=1

docker build \
  --cache-from myrepo/myapp:cache \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  -t myrepo/myapp:latest \
  -t myrepo/myapp:cache \
  .

docker push myrepo/myapp:cache
docker push myrepo/myapp:latest

5.2 Registry Cache with docker buildx (Recommended for Teams)

docker buildx build \
  --cache-from type=registry,ref=myrepo/myapp:buildcache \
  --cache-to   type=registry,ref=myrepo/myapp:buildcache,mode=max \
  --target runtime \
  --tag myrepo/myapp:latest \
  --push \
  .

mode=max exports all intermediate layer caches — critical for multi-stage builds so that the builder stage cache is reusable even when only the runner stage is pushed to production.

5.3 GitHub Actions Full Example

# .github/workflows/docker-build.yml
name: Build & Push

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: runtime
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
          cache-to:   type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max

6. Targeting Stages in Development Workflows

Multi-stage Dockerfiles serve double duty: use --target to pull out useful intermediate stages without changing the file.

# Run unit tests in CI (uses the 'test' stage from the Go example)
docker build --target test --tag myapp:ci-test .
docker run --rm myapp:ci-test

# Interactive debug session inside the builder environment
docker build --target builder --tag myapp:debug .
docker run --rm -it myapp:debug sh

# Build the slim production image
docker build --target runtime --tag myapp:prod .

This keeps your test infrastructure, build tooling, and runtime image all described in one file, with no Dockerfile sprawl.


7. Pitfalls to Avoid

7.1 Forgetting Runtime Dependencies

The most common failure: a binary that compiled fine refuses to start in scratch or distroless because a shared library is missing.

Diagnosis:

# Check dynamic dependencies BEFORE choosing a base image
docker run --rm -v $(pwd):/src alpine ldd /src/mybinary

If you see anything other than statically linked, you need CGO_ENABLED=0 (Go), a musl-based build, or a base image that includes glibc (e.g., gcr.io/distroless/base-debian12).

7.2 Ordering Stages Poorly and Breaking the Cache

Always copy dependency manifests (package.json, go.mod, requirements.txt) and install before copying source code. This is true in single-stage builds but even more important here — a cache miss in the deps stage cascades into every downstream stage.

# ✅ Cache-friendly: deps layer is only invalidated by lockfile changes
COPY package-lock.json package.json ./
RUN npm ci

# ✅ Source changes only invalidate from this layer downward
COPY src ./src
RUN npm run build

7.3 Using COPY --from with Relative Paths Incorrectly

COPY --from paths are always absolute from the source stage's filesystem root, regardless of the WORKDIR in the source stage. Be explicit:

# ✅ Explicit absolute source path
COPY --from=builder /app/dist ./dist

# ❌ This silently copies nothing if WORKDIR differs
COPY --from=builder dist ./dist

7.4 Ignoring .dockerignore

Large node_modules, .git, build artifacts, and secrets copied into the build context inflate every stage's initial COPY step. A tight .dockerignore is mandatory:

# .dockerignore
node_modules
.git
.env*
*.log
dist
coverage
__pycache__
.pytest_cache

7.5 Not Running the Final Image as a Non-Root User

A lean image with root is still a security liability. Every example in this article creates a dedicated low-privilege user — don't omit that step under deadline pressure.


8. Troubleshooting

Symptom Likely Cause Fix
exec /api: no such file or directory Missing shared libraries in scratch Use distroless or add CGO_ENABLED=0
Build ignores cache on every CI run Cache not exported/imported correctly Add --cache-to with mode=max and verify the registry ref
COPY --from stage not found Typo in stage name or --target stops before that stage Check stage names match exactly; build without --target once to verify
Final image larger than expected Dev dependencies or build artifacts copied accidentally Audit COPY source paths; check .dockerignore
Parallel stages not running in parallel BuildKit not enabled Set DOCKER_BUILDKIT=1 or use docker buildx build
permission denied at runtime Binary copied without execute bit Add RUN chmod +x /binary in the builder stage before copying

Key Takeaways

  • Multi-stage builds are a first-class Docker feature, not a workaround — use them as the default pattern for any image that involves a build step.
  • Name every stage with AS — it makes COPY --from and --target invocations self-documenting and refactor-safe.
  • Separate dependency installation from source copying so lockfile-unchanged builds hit the cache and skip the most expensive layers.
  • Use mode=max registry caching with docker buildx to make intermediate stage caches available to every CI runner, not just the machine that last built.
  • --target turns your Dockerfile into a multi-purpose tool: testing, debugging, and production builds from one file.
  • Always verify dynamic linking before choosing scratch or distroless; a runtime library mismatch is the most common production surprise.
  • Pair multi-stage builds with a strict .dockerignore — context bloat undermines the size savings before the first RUN even executes.

Next Steps

  1. Benchmark your current images with docker image inspect <image> | jq '.[0].Size' and set a size budget (e.g., < 100 MB) enforced in CI.
  2. Migrate your most-built service first — the CI caching savings compound fastest on the repo that runs builds most frequently.
  3. Explore gcr.io/distroless base images as an alternative to scratch when you need a shell-free image with glibc and TLS certs included.
  4. Add a lint or audit stage to your Dockerfile (e.g., npm audit, gosec, bandit) so security scanning runs inside the same cached layer graph as your build.
  5. Set up image scanning (Trivy, Grype, or Docker Scout) as a post-build step — smaller images mean fewer CVEs, and you will want to measure the improvement.

Related Articles

Thorsteinn Halldorsson Senior Cloud Engineer

Senior Cloud Engineer with 25+ years of hands-on experience across the datacenter-to-cloud stack: fiber SAN and disk storage, IBM/Lenovo blade and Dell/HP/Lenovo servers, Hyper-V and VMware clusters, and SQL and Remote Desktop Services (RDS) clusters. Deep in the Microsoft platform — Active Directory, PKI/certificate services, SQL, Power BI, Dynamics 365 Business Central (NAV) and AX (Axapta), Microsoft 365, Entra, and Intune — with a focus on Azure operations, FinOps, and applying AI tools like GitHub Copilot and Claude in real workflows. Writes practical, no-nonsense guides for IT professionals who need to ship real solutions.

Leave a Reply

Your email address will not be published. Required fields are marked *