Skip to content

Docker Multi-Stage Builds

CoreConceptJuly 21, 20267 min read

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.

Build stage has the toolchain; final stage ships only the artifact
Build stage has the toolchain; final stage ships only the artifact

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.

RUN rm in a later layer doesn't remove the file from earlier layers
RUN rm in a later layer doesn't remove the file from earlier layers

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.
Single stage — secret 'deleted' but still in image history
1FROM node:202WORKDIR /app3COPY . .4RUN --mount=type=secret,id=npm_token \5    NPM_TOKEN=$(cat /run/secrets/npm_token) npm install6RUN npm run build7RUN rm -rf node_modules/.cache  # looks like cleanup8CMD ["node", "dist/server.js"]9# Full toolchain, all devDependencies, and build-time layers all ship to production.10# Anything written to disk in an earlier RUN before this cleanup is still in the image.
Multi-stage — only the runtime artifact ships
1FROM node:20 AS builder2WORKDIR /app3COPY . .4RUN --mount=type=secret,id=npm_token \5    NPM_TOKEN=$(cat /run/secrets/npm_token) npm install6RUN npm run build7 8FROM node:20-slim9WORKDIR /app10COPY --from=builder /app/dist ./dist11COPY --from=builder /app/node_modules ./node_modules12CMD ["node", "dist/server.js"]13# The final image never had the builder stage's layers at all —14# nothing to accidentally leave behind, because it was never copied over.

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.

Named stages: FROM ... AS builder, then COPY --from=builder
Named stages: FROM ... AS builder, then COPY --from=builder

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.

Manifest-first ordering keeps the slow install step cached across source edits
Manifest-first ordering keeps the slow install step cached across source edits

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.
Source copied before install — cache invalidated by any edit
1FROM node:20 AS builder2WORKDIR /app3COPY . .              # any file change invalidates everything below4RUN npm install        # reinstalls from scratch on every source edit5RUN npm run build
Manifest first, cache mount for the package cache
1FROM node:20 AS builder2WORKDIR /app3COPY package.json package-lock.json ./4RUN --mount=type=cache,target=/root/.npm npm install5COPY . .                # only this and below rebuild on a source edit6RUN npm run build

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.

Size and attack surface traded against live-debug capability
Size and attack surface traded against live-debug capability

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.

Share:

Related Articles

Containers are ephemeral by design — when you remove one, its filesystem disappears with it. That is fine for stateless

Read

Docker and Kubernetes are not competitors on the same layer, and treating them as an either/or is the mistake that leads

Read

Manual deployments are one of the highest-risk activities in software engineering. A developer SSHes into a production s

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.