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
FROMblocks in one Dockerfile. Each is a stage. - Stages can be named with
AS <name>. Later stages reference earlier ones viaCOPY --from=<name>. - Only the last stage is the final image (unless you specify
--targetat 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
# 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 /app/dist /usr/share/nginx/html
EXPOSE 80$ 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 MBThe 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
| Approach | Final image size |
|---|---|
Single stage (node:22 + everything) | 600-900 MB |
Multi-stage with node:22-alpine final | 200-300 MB |
Multi-stage with nginx:alpine for static | 25-30 MB |
Multi-stage with distroless final | 20-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
FROM golang:1.23 AS build # named stage
# ...
FROM alpine:3.21 AS test # another named stage
COPY /out/server /server
RUN /server --self-test
FROM alpine:3.21
COPY /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
# 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
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
FROM scratch
COPY /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
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 /app/dist /usr/share/nginx/htmlReact, Vue, Svelte build artifacts served by nginx. The Node toolchain stays in the builder; the final image is tiny.
Pattern 3: compile, then distroless
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 /app/dist /app/dist
COPY /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
# 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 /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"
# WRONG: defeats the purpose
FROM nginx:1.27-alpine
RUN apk add --no-cache curl jq vim git # adds 50+ MB
COPY /app/dist /usr/share/nginx/htmlIf 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
# WRONG: copies all node_modules including dev deps
COPY /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 stageDev deps (TypeScript compiler, eslint, jest) can double the size of node_modules. Prune them.
Using latest tags in build stages
# 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 buildMulti-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 testruns the test stage;docker build --target prodproduces 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 buildxfor 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
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 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY /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
# 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 /app/dist /usr/share/nginx/html# 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 readyA concise answer to help you respond confidently on this topic during an interview.
Comments
No comments yet