Skip to main content

What is a multi-stage Docker build and why use it?

Multi-stage builds are the standard way to ship small, secure Docker images. The idea: build heavy, ship light. Use a fat builder image with all your compilers and tools, copy only the final artifact into a minimal runtime stage. The toolchain never lands in production.

Theory

TL;DR

  • Multiple FROM blocks in one Dockerfile. Each is a stage.
  • Stages can be named with AS <name>. Later stages reference earlier ones via COPY --from=<name>.
  • Only the last stage is the final image (unless you specify --target at build time).
  • Common pattern: stage 1 = full SDK (compilers, package managers); stage 2 = minimal runtime (alpine, distroless, scratch).
  • Result: a 30 MB final image instead of 600 MB. Smaller attack surface, faster pulls, safer.

Quick example

dockerfile
# Stage 1: build (full Node toolchain, ~300 MB while building) FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # produces /app/dist # Stage 2: runtime (small nginx image) FROM nginx:1.27-alpine COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80
bash
$ docker build -t mysite . [+] Building 12.4s => [build 1/5] FROM node:22-alpine => [build 2/5] WORKDIR /app => [build 3/5] COPY package*.json ./ => [build 4/5] RUN npm ci # 200 MB of node_modules => [build 5/5] RUN npm run build # produces /app/dist => [stage-1 1/2] FROM nginx:1.27-alpine => [stage-1 2/2] COPY --from=build /app/dist /usr/share/nginx/html => exporting to image # final image ~25 MB

The Node SDK and node_modules existed in the build stage but never ship. Only the final compiled output (/app/dist) makes it into the image.

Why it matters: numbers

ApproachFinal image size
Single stage (node:22 + everything)600-900 MB
Multi-stage with node:22-alpine final200-300 MB
Multi-stage with nginx:alpine for static25-30 MB
Multi-stage with distroless final20-50 MB
Multi-stage with FROM scratch (Go binary)5-15 MB

A 30x size reduction is common. Pull time across hundreds of nodes drops from minutes to seconds.

Naming and referencing stages

dockerfile
FROM golang:1.23 AS build # named stage # ... FROM alpine:3.21 AS test # another named stage COPY --from=build /out/server /server RUN /server --self-test FROM alpine:3.21 COPY --from=build /out/server /server # ...
  • AS <name> names a stage; reference by name with --from=<name>.
  • You can also reference by index (--from=0, --from=1), but names are more readable.
  • COPY --from=<external-image> works too (COPY --from=alpine:3.21 /etc/passwd /tmp/) — pulls files out of any image.

Building only one stage

bash
# Build the entire Dockerfile, get the final image docker build -t myapp . # Build only the 'build' stage (useful for CI integration tests) docker build --target build -t myapp:dev .

The --target flag stops at the named stage and uses that as the output image. Useful for running tests against the build stage before final assembly.

Common patterns

Pattern 1: compiled language → minimal runtime

dockerfile
FROM golang:1.23-alpine AS build WORKDIR /src COPY . . RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server FROM scratch COPY --from=build /out/server /server USER 65532:65532 ENTRYPOINT ["/server"]

A Go binary in a scratch image. Final size: the size of the binary. No shell, no libc, no surface.

Pattern 2: build static assets, serve with nginx

dockerfile
FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:1.27-alpine COPY --from=build /app/dist /usr/share/nginx/html

React, Vue, Svelte build artifacts served by nginx. The Node toolchain stays in the builder; the final image is tiny.

Pattern 3: compile, then distroless

dockerfile
FROM node:22 AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build RUN npm prune --omit=dev FROM gcr.io/distroless/nodejs22 COPY --from=build /app/dist /app/dist COPY --from=build /app/node_modules /app/node_modules WORKDIR /app CMD ["dist/server.js"]

Node app on Google's distroless image. Has Node runtime, but no shell, no apt, no anything else.

Common mistakes

Forgetting --from= and copying from the local build context

dockerfile
# WRONG: copies from build context (your laptop), not from the build stage FROM nginx:1.27-alpine COPY dist/ /usr/share/nginx/html/ # would need dist/ in build context # RIGHT: copy from the named stage FROM nginx:1.27-alpine COPY --from=build /app/dist /usr/share/nginx/html/

Without --from, COPY uses the build context. With --from=<stage>, COPY uses that stage's filesystem.

Bloating the runtime stage with build tools "just in case"

dockerfile
# WRONG: defeats the purpose FROM nginx:1.27-alpine RUN apk add --no-cache curl jq vim git # adds 50+ MB COPY --from=build /app/dist /usr/share/nginx/html

If you find yourself adding tools to the runtime stage, ask why. Debug tools belong in a :debug variant, not in production. Build tools belong in the build stage.

Not pruning dev dependencies

dockerfile
# WRONG: copies all node_modules including dev deps COPY --from=build /app/node_modules /app/node_modules # RIGHT: prune in build stage first RUN npm prune --omit=dev # in build stage # OR: install only prod deps in a separate stage

Dev deps (TypeScript compiler, eslint, jest) can double the size of node_modules. Prune them.

Using latest tags in build stages

dockerfile
# WRONG: the build is non-reproducible FROM node:latest AS build # what version did this build with? # RIGHT: pin the version FROM node:22.11-alpine AS build

Multi-stage does not absolve you of the latest problem. Pin every FROM.

Real-world usage

  • All major cloud-native projects: Kubernetes, Prometheus, Grafana — every official image is multi-stage. Final images are tiny.
  • CI/CD pipelines: docker build --target test runs the test stage; docker build --target prod produces the deploy artifact. One Dockerfile, multiple outputs.
  • Cross-compilation: stage 1 cross-compiles for ARM, stage 2 is a slim ARM base. Combined with docker buildx for multi-arch.
  • Minimizing supply-chain risk: every package not in the final image is one less CVE to scan. Fortune-500 security teams require multi-stage for production images.

Follow-up questions

Q: How does Docker know which stage is the final one?


A: It is the last stage in the Dockerfile, unless you pass --target <stage> at build time. Anything after the --target stage is ignored.

Q: Can stages run in parallel?


A: Yes, with BuildKit. Stages with no dependency on each other run concurrently. Stages with COPY --from=other wait for other to finish.

Q: What is the difference between multi-stage and using multiple Dockerfiles?


A: Multi-stage = one file, one docker build invocation, internal stages. Multiple Dockerfiles = one file per stage, manual coordination. Multi-stage is simpler, has cleaner caching, and is the modern norm.

Q: Does each stage produce a separate image?


A: Internally, yes — but only the final stage gets a tag and ends up in your image list. Intermediate stages are kept in the build cache and can be referenced or rebuilt.

Q: (Senior) How would you use multi-stage builds for security scanning?


A: Add a scan stage between build and runtime: FROM aquasec/trivy AS scan + RUN trivy fs --severity HIGH,CRITICAL --exit-code 1 /from-build. The build fails if HIGH/CRITICAL CVEs are found. Combined with --target scan in CI, you have a non-skippable security gate. The runtime stage is unaffected.

Examples

Go service in scratch

dockerfile
FROM golang:1.23-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server ./cmd/server FROM scratch COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=build /out/server /server EXPOSE 8080 USER 65532:65532 ENTRYPOINT ["/server"]

Final image: ~10 MB (just the Go binary plus CA certs for HTTPS). Smaller than the source code.

Three-stage: build → test → runtime

dockerfile
# Stage 1: build FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # Stage 2: test (uses build stage output) FROM build AS test RUN npm test # fail the whole build if tests fail # Stage 3: runtime (slim final image) FROM nginx:1.27-alpine COPY --from=build /app/dist /usr/share/nginx/html
bash
# CI: run tests; fail-fast $ docker build --target test . # CI: build prod artifact (skips test stage if not depended on) $ docker build -t mysite .

Same Dockerfile, multiple workflows. Tests live where their dependencies live; production has neither.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Comments

No comments yet