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

- A docker multi-stage build uses multiple
FROMinstructions 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 selectiveCOPY --frominstructions 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=1or usedocker 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).

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-depsstage? It runs in parallel withbuilderunder BuildKit, shaving wall-clock time off your CI pipeline. Both stages only re-run whenpackage-lock.jsonchanges.
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 makesCOPY --fromand--targetinvocations 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=maxregistry caching withdocker buildxto make intermediate stage caches available to every CI runner, not just the machine that last built. --targetturns your Dockerfile into a multi-purpose tool: testing, debugging, and production builds from one file.- Always verify dynamic linking before choosing
scratchordistroless; 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 firstRUNeven executes.
Next Steps
- Benchmark your current images with
docker image inspect <image> | jq '.[0].Size'and set a size budget (e.g., < 100 MB) enforced in CI. - Migrate your most-built service first — the CI caching savings compound fastest on the repo that runs builds most frequently.
- Explore
gcr.io/distrolessbase images as an alternative toscratchwhen you need a shell-free image with glibc and TLS certs included. - Add a
lintorauditstage to your Dockerfile (e.g.,npm audit,gosec,bandit) so security scanning runs inside the same cached layer graph as your build. - 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
- Distroless vs. Alpine vs. Scratch: Choosing the Right Docker Base Image — Trade-off analysis of minimal base images with real CVE counts and size benchmarks.
- Docker BuildKit Deep Dive: Cache Mounts, Secret Mounts, and SSH Forwarding — Go beyond
--cache-fromwithRUN --mount=type=cachefor package managers and private repos. - Kubernetes Pod Startup Optimisation: From Image Pull to Ready in Under 5 Seconds — How image size, registry proximity, and pre-pulled images affect time-to-ready in production clusters.
Leave a Reply