DevOps · Docker

Docker Multi-Stage Builds — A Field Guide

Docker Multi-Stage Builds — A Field Guide is the work that turns a deploy into a system. The deployment is one moment; the system is the next 18 months of uptime, incidents, and.

John Kihiu12 min read

A multi-stage Dockerfile uses more than one `FROM` instruction in the same file, where each stage can build on a previous one and — critically — the final image only contains what you explicitly `COPY --from=` into it. The build toolchain, source files, and intermediate artifacts from earlier stages never make it into the image you ship, unless you copy them in yourself.

The single-stage problem it replaces

Before multi-stage builds existed (Docker 17.05+), the common workaround was a two-Dockerfile setup or a "builder" image tagged separately, then manually copying artifacts out with `docker cp` in a CI script. A single-stage Dockerfile that compiles a Go binary or bundles a Node app ends up shipping the compiler, the full `node_modules` including devDependencies, and every intermediate build file — often hundreds of megabytes of pure attack surface and pull-time cost that provides zero runtime value.

DOCKERFILE
# Stage 1: install deps and build
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: production image, only the output
FROM node:20-slim AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev
USER node
CMD ["node", "dist/server.js"]

Named stages make the Dockerfile self-documenting

Naming each stage with `AS ` (rather than referring to stages by index) makes a multi-stage Dockerfile readable and lets later stages, or even a `docker build --target build` invocation, reference an intermediate stage directly — useful for running just the build-and-test stage in CI without producing the final production image at all. `COPY --from=build` pulls specific files from a named stage; you can copy from multiple different stages into the final one, which is common when a build needs both a compiled binary and a separately-generated static asset bundle.

Layer caching still applies per stage

Docker's build cache operates on each stage independently, so ordering still matters inside a multi-stage build the same way it does in a single-stage one: copy `package.json` and run `npm ci` before copying the rest of the source, so dependency installation is cached and only re-runs when dependencies actually change, not on every source edit. Buildx's `--cache-from`/`--cache-to` with a registry backend extends this across CI runs, not just within a single build.

Order instructions from least to most frequently changed

Dependency manifests change rarely; application source changes constantly. Copying and installing dependencies first means a source-only change only invalidates the cache from that `COPY . .` line onward, keeping most rebuilds fast.

Pairing the final stage with a minimal base

Multi-stage builds and minimal base images (Alpine, `-slim` variants, or distroless) solve complementary problems: multi-stage strips your own build tooling out of the shipped image, while a minimal base strips the OS-level tooling you never asked for. Combining both is how a Go service Dockerfile goes from a 900MB single-stage image built on a full `golang` base down to a 15-20MB final image — a build stage on `golang:1.22`, a runtime stage on `gcr.io/distroless/static-debian12` or `scratch` for a fully static binary.

Watch for accidentally copied secrets

A `COPY . .` in a build stage that includes a `.env` file or private key doesn't automatically leak into the final image — but if you later `COPY --from=build /app .` (copying the whole build directory instead of just the output), it does. Copy specific paths, never the whole build context, into the final stage.

ApproachTypical final image size
Single-stage, full base500MB - 1GB+
Multi-stage, slim runtime base80-150MB
Multi-stage, distroless/scratch5-30MB

Wrapping up

Multi-stage builds cost nothing at runtime and pay for themselves immediately in image size, pull time, and reduced attack surface — there's rarely a reason to ship a build toolchain in a production image once you know the pattern. Order instructions for cache efficiency, name your stages, and copy only the specific paths the final image actually needs.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.