Run docker history on a typical production image and you will often find a compiler, a package manager, a shell, git, and several hundred megabytes of build tooling riding along with a binary that needs none of it. That tooling did its job at build time; at runtime it is dead weight that slows down pulls, costs storage and bandwidth, and — worse — hands every process in the container (and every attacker who compromises one) a ready-made toolkit. Multi-stage builds are Docker's answer: build everything in one stage, then copy only the artifacts you need into a minimal final image, leaving the toolchain behind. The feature has been available since Docker 17.05, and it remains the single highest-leverage change you can make to a production Dockerfile.
How multi-stage builds work
A multi-stage Dockerfile contains more than one FROM instruction. Each FROM starts a new stage — a fresh filesystem built from a (possibly different) base image. Stages can copy files from each other with COPY --from=<stage>, and only the last stage in the file becomes the image you tag and ship. Everything in the earlier stages — compilers, dev dependencies, intermediate files, test fixtures — is discarded.
Only the last stage becomes the image you tag and ship; everything in the earlier stages — compilers, dev dependencies, test fixtures — is discarded. Stages are numbered from 0 by default, but naming them with AS <name> is strongly preferable: the name survives later reordering of the Dockerfile, and COPY --from=builder reads like documentation. COPY --from can also pull from any local or registry image, not just your own stages.
Build a single stage on demand with docker build --target <stage> — the standard way to get a debug variant (shell included) and a lean production variant out of one Dockerfile. One subtlety: the legacy Docker builder processes all stages up to the target, while BuildKit — the modern default — builds only the stages the target actually needs.
A realistic example: Node.js
Consider a TypeScript API. A single-stage Dockerfile would ship typescript, ts-node, every dev dependency, and the source tree alongside the running code. The multi-stage version splits the work into three stages:
# Stage 1: install production dependencies onlyFROM node:22-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=dev# Stage 2: build the applicationFROM node:22-alpine AS buildWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN npm run build# Stage 3: the production imageFROM node:22-alpine AS productionWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY --from=build /app/dist ./distUSER nodeEXPOSE 3000CMD ["node", "dist/index.js"]The final image contains the compiled dist/ output, the production dependencies, and the Node.js runtime — no TypeScript, no dev dependencies, no source. Two details worth noting: use npm ci (never npm install) for deterministic, lockfile-based installs in builds, and USER node switches to the unprivileged node user built into the official image — running as root is the default and a needless risk.
A realistic example: Go
Compiled languages benefit even more, because the final image can drop the entire toolchain. A statically linked Go binary needs no Go SDK, no libc, and not even a shell:
# Stage 1: compileFROM golang:1.24-alpine AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server ./cmd/server# Stage 2: runFROM gcr.io/distroless/static-debian13:nonrootCOPY --from=build /server /serverENTRYPOINT ["/server"]The final image is a single statically compiled binary on top of a ~2 MiB base — versus a full golang image of roughly a gigabyte. CGO_ENABLED=0 produces a static binary that runs on the static distroless base (CA certificates, timezone data, and a nonroot user entry, nothing else). Because distroless images have no shell, ENTRYPOINT must use the JSON (exec) form — the shell form silently fails without /bin/sh.
Minimal production images: Alpine, distroless, scratch
Choosing the final-stage base image is the other half of “smaller and safer”. The common options, roughly in order of minimalism:
- Alpine (~5 MiB): a very small but complete distribution with a shell and package manager — easy to debug, but note the musl libc caveat for prebuilt glibc binaries.
- Distroless (~2 MiB for
static): language-focused images with your runtime and nothing else — no shell, no package manager. Thenonroottag runs as UID 65532; adebugvariant with a BusyBox shell covers the rare occasion you need to look inside. scratch(empty): your statically linked binary and nothing else. Zero patch surface beyond your own code, but you provide everything the base normally would, including CA certificates for TLS.
Alpine is the pragmatic default when teams still shell into containers; distroless suits production services with mature observability. Either way, keep a --target debug stage with a shell available so a missing shell in production is an inconvenience, not a crisis.
Layer caching pitfalls
Multi-stage builds make images smaller, but the layer cache is what makes them fast. Docker caches the result of each instruction and reuses it when nothing about that instruction or its inputs changed — the moment one layer changes, every layer after it rebuilds. Most slow builds are instruction-ordering problems, not Docker problems.
Order instructions by change frequency. Dependency manifests (package-lock.json, go.mod, requirements.txt) change rarely; source code changes constantly. Copy the manifest first, install, then copy the source:
COPY go.mod go.sum ./RUN go mod downloadCOPY . .With this order, editing a Go source file reuses the cached go mod download layer. Reverse the order and every keystroke redownloads every dependency.
Use .dockerignore aggressively. The build context — .git, node_modules, .env files — is sent to the Docker daemon on every build, and any change to a file matched by COPY . . busts the cache. A committed .dockerignore excluding those paths speeds up builds and reduces the chance of leaking secrets into layers.
Beware the cached apt-get update. If RUN apt-get update sits in its own layer, Docker reuses the cached layer on later builds and apt-get install runs against a stale package index — silently installing old (potentially vulnerable) versions. The fix is to combine update and install in one RUN and clean up in the same layer, so no stale index survives in the final image:
RUN apt-get update && apt-get install -y --no-install-recommends \ libpq5 \ && rm -rf /var/lib/apt/lists/*Build args bust the cache silently. ARG values are part of the cache key: changing a build arg invalidates the cache for every instruction from that ARG onward. Declare args as late as possible — ideally just before they're used.
Know the difference between --pull and --no-cache. docker build --no-cache re-executes every instruction but keeps your old base image; docker build --pull fetches the newest base image first. Combine them (--pull --no-cache) when you want a genuinely fresh build — for example, a scheduled rebuild to pick up base-image security patches. Docker's own best-practices guide emphasizes rebuilding images regularly, because an image is a snapshot of its base and dependencies at build time.
For heavy rebuilds, use BuildKit cache mounts. Dependencies downloaded outside the image layers persist across builds without bloating layers:
RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg/mod \ go build -o /server ./cmd/serverThe module cache survives between builds, so subsequent builds only fetch what changed. The mounts exist only during the build — nothing leaks into the final image.
The security payoff
All of this converges on security, not just size. A multi-stage build lets you:
- Shrink the attack surface. Build tools, compilers, package managers, and source never reach production — the final image holds only what the process executes.
- Keep build-time secrets out of layers. Credentials used to fetch dependencies can live in an intermediate stage; only the compiled artifacts are copied forward. Prefer BuildKit secret mounts (
RUN --mount=type=secret), which never land in any layer — and remember intermediate stages persist in the build cache on the build host, so treat build hosts as sensitive. - Run as non-root, with no shell for an attacker to reach for.
- Make scanning meaningful. When the image ships 10 packages instead of 300, the vulnerability report is signal instead of noise.
One caveat to keep honest: smaller isn't always better — chasing the absolute smallest image can cost you debuggability and compatibility — and intermediate layers are still visible to anyone with access to the build host. Multi-stage builds are a build-time tool, not a secrets manager.
A checklist to take with you
- Name every stage (
AS builder,AS production) and reference them by name. - Copy dependency manifests before source; install before copying the app.
- Commit a
.dockerignorethat excludes.git,node_modules, and secrets. - Use
npm ci/ lockfile-based installs, never unpinnednpm install. - Pick a minimal final base (Alpine, distroless, or
scratch) and pin the tag or digest. - Run the final stage as a non-root user.
- Keep a
--target debugstage with a shell for production debugging. - Rebuild regularly with
--pullso base images get their security patches.
Further reading
- Multi-stage builds | Docker Docs — the official reference: stage naming,
COPY --fromsemantics,--targetbuilds, and how BuildKit differs from the legacy builder. - Building best practices | Docker Docs — Docker's own guidance on base image selection, cache management,
.dockerignore, and rebuilding for security patches. - GoogleContainerTools/distroless — the distroless image project: available images and tags (
nonroot,debug), entrypoint rules for shell-less images, and Docker usage examples per language.






