Suggest an editImprove this articleRefine the answer for “What is a multi-stage Docker build and why use it?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**A multi-stage Docker build** uses multiple `FROM` blocks in one Dockerfile. Earlier stages compile/build the artifact; the final stage copies just the artifact into a slim runtime image, leaving the build toolchain behind. ```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 # ← second stage COPY --from=build /app/dist /usr/share/nginx/html ``` **Key:** the final image contains only what `--from=build` copied. Build deps (`npm`, source code, `node_modules`) never ship. Result: smaller image, smaller attack surface, faster pull.Shown above the full answer for quick recall.Answer (EN)Image**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 | 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 ```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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.