Docker Multi-Stage Builds
Building software needs compilers, SDKs, dev dependencies, and headers. Running it usually needs a fraction of that. A multi-stage build uses several FROM instructions in one Dockerfile: early stages do the heavy building, and the final stage copies in only the finished artifacts. The toolchain never reaches production.
The payoff is dramatic. A Go service built on golang:1.23 is about 800 MB; the same binary on scratch or distroless is 10–20 MB. Smaller images pull faster, start faster, and carry far fewer CVEs. It's the biggest single improvement most Dockerfiles can make.
TL;DR
- Each
FROMstarts a new stage; name it withAS builder. COPY --from=builder /path /pathpulls artifacts from an earlier stage into a later one.- Only the final stage ends up in the image; earlier stages are discarded (but cached).
- Pair a full SDK builder with a minimal runtime: slim, distroless, or
scratchfor static binaries. docker build --target <stage>builds an intermediate stage, for test or dev images from the same file.- BuildKit skips unused stages and runs independent stages in parallel.
Quick Example
A Go service compiled in a full toolchain image and shipped on distroless:
The final image contains one static binary plus CA certificates and timezone data: no shell, no package manager, no Go toolchain.
Core Concepts
Stages and COPY --from
Every FROM begins a fresh filesystem. Name stages with AS and reference them by name. You can also copy from any external image, which is handy for grabbing a single tool:
Build Targets
--target stops the build at a named stage. One Dockerfile can then serve several purposes:
Parallelism and Skipping
BuildKit builds a dependency graph of stages. Stages that don't depend on each other run concurrently, such as building a frontend bundle and a backend binary at once. Stages the target doesn't need are skipped entirely.
Patterns by Language
Node.js: Build, Then Prune
TypeScript compilers, bundlers, and test frameworks stay in build; only production dependencies and compiled output ship.
Python: Build Wheels or a Virtualenv
Compilers and headers needed for native extensions stay in the full image. The virtualenv copies cleanly as long as both stages share the same Python version and libc.
Java: JDK to Build, JRE to Run
Build with a Maven or Gradle image carrying a JDK, then copy the JAR into a JRE-only image such as eclipse-temurin:21-jre. For extra savings, use jlink in the build stage to produce a custom minimal runtime.
Best Practices
Match the Runtime to the Artifact
- Static binaries (Go with
CGO_ENABLED=0, Rust with musl) →scratchordistroless/static. - Dynamically linked binaries →
distroless/baseordebian:*-slim. - Interpreted languages → the
-slimvariant of the same runtime version, or a distroless language image.
Keep Dependency Stages Cacheable
Apply the usual ordering inside each stage: copy manifests, install dependencies, then copy source. A shared deps stage that build and test both inherit from avoids installing twice.
Run Tests in a Stage
A test stage built FROM build that runs your test suite lets CI use --target test. The production target never includes test tooling.
Debug Minimal Images Deliberately
Distroless and scratch have no shell. Use :debug distroless variants during development, or attach an ephemeral debug container (kubectl debug, docker debug) instead of adding a shell back into production images.
Common Mistakes
Copying the Whole Build Directory
Mismatched Libc Between Stages
Building on node:22 (glibc) and running on node:22-alpine (musl) breaks native modules like bcrypt and sharp. Build and run on the same libc family, or rebuild native modules in the runtime base.
Forgetting CA Certificates and Timezones on scratch
A static binary on scratch can't verify TLS without CA certificates. Copy /etc/ssl/certs/ca-certificates.crt from the builder, or use distroless/static, which includes them.
FAQ
Do intermediate stages make the final image bigger?
No. Only the final stage's filesystem is exported. Intermediate stages remain in the build cache to speed up future builds, but they're not part of the pushed image.
Can I build multiple images from one Dockerfile?
Yes. Define a final stage per image and select it with --target, or use docker buildx bake to build several targets in parallel from one configuration.
How do I cache multi-stage builds in CI?
Export the full cache, including intermediate stages, with --cache-to type=registry,mode=max or the GitHub Actions cache backend (type=gha,mode=max). mode=min caches only the final stage's layers and misses most of the benefit.
Is distroless always better than slim?
Distroless is smaller and has less attack surface, but no shell or package manager makes debugging and some entrypoint scripts harder. Slim is a pragmatic middle ground. Many teams use distroless for compiled services and slim for interpreted ones.
Related Topics
- Docker — The container platform overview
- Dockerfile Best Practices — Caching, ordering, and security habits
- Container Security — Why minimal runtime images matter
- Go — Static binaries ideal for scratch images
- Build Tools — The toolchains you're leaving behind