Docker Multi-Stage Builds
A team notices their production image is 1.4GB for an app whose actual runtime code is a few megabytes — the rest is a full compiler toolchain, package manager caches, and source files that were only ever needed to build the app, not run it. Worse: someone once baked an API key into an early build layer, deleted it with a later RUN rm, and the image still contains it — RUN rm in a later layer never removes anything from the layers before it, because Docker layers are additive and the deleted file's earlier layer is still shipped as part of the image's history.
This guide covers the actual fix — multi-stage builds — and the mechanism that makes it work: separate build and runtime stages, only copying the specific artifacts a runtime actually needs. It also covers the layer-cache ordering that makes rebuilds fast, BuildKit's cache mounts for package-manager caches, and picking a final base image deliberately instead of by habit. For the difference between a container image and running it under an orchestrator, see Docker vs Kubernetes.
Why One Stage Isn't Enough
A single-stage Dockerfile installs a full compiler, downloads every dev dependency, builds the app, and ships all of that — toolchain included — in the exact same image that runs in production. The bloat is the visible problem; the security issue is worse and less visible: any file written in an earlier RUN or COPY layer is part of that layer, permanently, and a later RUN rm only marks it deleted in a new, additional layer on top — the file still physically exists in the image's layer history and can be recovered by anyone who can pull the image and inspect its layers.
This means a build-time secret (an API key fetched to pull a private dependency, a signing credential) that ever touched an early layer is not actually gone just because a later step deletes it — it's still sitting in the image, one docker history and layer extraction away from being read by anyone with pull access. The only mechanism that genuinely excludes something from the final image is never including that layer in the final stage at all — which is exactly what multi-stage builds are for.
Quick reference
- RUN rm in a later layer does not remove a file's bytes from the image — Docker layers are additive history, not a mutable filesystem.
- Anything written in any layer of the final image is recoverable from that image, regardless of later deletion in a subsequent layer.
- A single-stage build ships its entire build toolchain (compiler, package manager, dev dependencies) to production by default.
- Multi-stage separation is the actual fix for both bloat and accidental secret exposure — not cleanup commands layered after the fact.
- BuildKit's --mount=type=secret keeps a secret out of any layer entirely for the duration of one RUN — the safest way to use a credential during a build stage.
Remember this
A file deleted with RUN rm in a later layer is still physically present in the image's earlier layers — the only way to genuinely exclude a build-time artifact or secret is to never copy that stage's layers into the final image at all.
Multi-Stage Syntax: Named Stages and COPY --from
A multi-stage Dockerfile has more than one FROM instruction, each starting a fresh stage with its own base image and layer history. Naming a stage with AS builder lets a later stage reference it explicitly: COPY --from=builder /app/dist ./dist copies only that specific path from the named stage's filesystem — nothing else from the builder stage comes along. A Dockerfile can have as many stages as needed (a dependencies stage, a test stage, a build stage), and only the final FROM block (or one selected with docker build --target) becomes the actual image that gets tagged and shipped.
Stages don't have to share a base image or even a language — a Go binary's build stage can use the full golang image while its final stage uses a minimal alpine or distroless base with nothing but the compiled binary, because Go produces a statically-linked executable that doesn't need Go itself present at runtime at all.
Quick reference
- Multiple FROM instructions = multiple stages, each with independent layer history.
- AS <name> lets later stages reference an earlier one by name in COPY --from, instead of a fragile numeric index.
- COPY --from=<stage> <path> copies only that specific path — the rest of that stage's filesystem is never included.
- docker build --target=<stage> builds and stops at a named stage — useful for a dedicated test stage you run in CI without shipping it.
- Stages can use entirely different base images — a compiled-language build stage commonly ships a final stage with no compiler present at all.
Remember this
Named stages plus COPY --from let a Dockerfile use a full toolchain to build and a minimal, unrelated base image to ship — the final image only ever contains the specific paths explicitly copied from earlier stages.
Layer Caching Order and BuildKit Cache Mounts
Docker caches each instruction's resulting layer and reuses it on a rebuild if nothing that affects it has changed — but the moment one instruction's inputs change, every instruction after it in that stage rebuilds from scratch, cached or not. This makes instruction order a real performance decision: copying the entire source tree before installing dependencies means any source file edit invalidates the dependency-install layer's cache too, even though the dependencies themselves didn't change — forcing a full reinstall on every rebuild.
The fix is copying only the dependency manifest first, installing, and then copying the rest of the source — so an ordinary code change only invalidates the (usually fast) final copy-and-build steps, not the (usually slow) dependency install. BuildKit's --mount=type=cache goes further: it persists a directory (like npm's or pip's download cache) across builds without that cache ever becoming part of any image layer at all — faster rebuilds without the cache bloating the image the way baking it into a regular layer would.
Quick reference
- A layer rebuilds if its own instruction or any earlier layer in the same stage changed — order determines what stays cached.
- Copy dependency manifests and install before copying the rest of the source, so ordinary code edits skip the slow install step.
- --mount=type=cache persists a directory across builds without it becoming part of any shipped image layer.
- Cache mounts are ideal for package-manager download caches (npm, pip, apt) — fast rebuilds, zero image-size cost.
- Each stage has independent cache lineage — reordering one stage's instructions doesn't affect another stage's cache validity.
Remember this
Instruction order decides what a rebuild can reuse — copying dependency manifests before source code, plus a BuildKit cache mount for the package manager's own cache, is what keeps ordinary rebuilds fast without bloating the shipped image.
Choosing a Final-Stage Base Image
The final stage's base image is a deliberate trade between size, security surface, and operability. Alpine is small and has a shell and package manager for debugging, but its musl libc occasionally causes subtle compatibility issues with binaries built against glibc elsewhere. Distroless (Google's images) strips out the shell and package manager entirely, leaving just the language runtime — smaller attack surface than Alpine, at the cost of not being able to exec into the container for a quick shell debug session. Scratch is completely empty — no shell, no libc, nothing — appropriate only for a fully static binary (a Go build with CGO_ENABLED=0, or Rust with the musl target) that needs nothing from the base image at all.
The debugging trade-off is real and worth deciding deliberately rather than defaulting to whatever the tutorial used: a distroless or scratch production image is harder to poke at with docker exec sh during an incident, which is a legitimate cost some teams accept for the security benefit and others don't, especially early on when operational familiarity matters more than a minimal attack surface.
Quick reference
- Alpine: small, has a shell for debugging, musl libc can occasionally surprise binaries expecting glibc.
- Distroless: no shell, no package manager, smaller attack surface, harder to exec into for live debugging.
- Scratch: completely empty — only valid for a fully static binary with zero runtime dependencies on the base image.
- Losing shell access to debug a running container is a real operational cost of distroless/scratch, not a footnote.
- Pick the base per service based on actual static-linking capability and how your team debugs incidents — not by copying whichever base a tutorial happened to use.
- Runtime storage (named volumes, bind mounts) is a separate concern from build-time layers — see Docker Volumes Explained for persistence patterns.
Remember this
Alpine, distroless, and scratch trade size and attack surface against the ability to exec into a running container for debugging — pick deliberately based on whether your binary can actually be fully static and how your team handles incidents.
Key takeaway
A single-stage Dockerfile ships its entire build toolchain to production and can leave a build-time secret physically present in the image even after a later RUN rm — Docker layers are additive, not mutable. Multi-stage builds fix both: named stages with COPY --from let only specific runtime artifacts cross into the final image, which never includes the builder stage's layers at all. Ordering dependency installs before source copies, plus BuildKit cache mounts, keeps rebuilds fast without that speed costing image size.
Practice (25 min): write a single-stage Dockerfile for a small app that bakes in a fake secret file, deletes it with a later RUN rm, then run docker history and extract an earlier layer to confirm the 'deleted' secret is still recoverable. Rewrite it as a multi-stage build with a builder stage and a minimal final stage, and confirm the same extraction now finds nothing. Reorder your COPY and RUN install steps so a source-only edit no longer reinstalls dependencies, and add a BuildKit cache mount for your package manager's cache. Pass when your final image is a fraction of the single-stage size and contains no trace of the builder stage's secret.
Related Articles
Explore this topic