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

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

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

References