Multi-Stage Build
A Dockerfile pattern that uses multiple FROM instructions to separate build-time dependencies from runtime artifacts. The final image contains only what is needed to run the application — not the compilers, test frameworks, or build tools used during the build.
Multi-Stage Build — Building Small, Secure Production Images
What Is a Multi-Stage Build in Simple Terms?
A multi-stage build solves one of the most common Docker problems: build tools are heavy, but you only need them during the build. Your Go compiler is 500MB. Your final binary is 10MB. A single-stage Dockerfile keeps both in the image forever. A multi-stage build uses the compiler in one stage, copies only the binary to the final stage, and throws everything else away.
The result is a production image that is 5-10x smaller and has dramatically fewer CVEs because it contains no build tools, no compilers, no test frameworks — just what the running application needs.
+------------------------------------------+| Stage 1: builder || FROM node:20 AS builder || Install ALL deps (dev + prod) || Copy source code || Run build (TypeScript -> JavaScript) || Result: compiled output in /app/dist |+------------------------------------------+ | COPY --from=builder /app/dist | (only the compiled output) v+------------------------------------------+| Stage 2: production (final image) || FROM node:20-alpine || Install ONLY production deps || Copy compiled output from builder || No source code, no dev tools, no tsc || Result: 180MB instead of 1.8GB |+------------------------------------------+Complete Multi-Stage Dockerfile Examples
Node.js / TypeScript:
# syntax=docker/dockerfile:1 # Stage 1 — BuildFROM node:20-alpine AS builderWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci # all deps including devDependenciesCOPY . .RUN npm run build # TypeScript -> dist/ # Stage 2 — ProductionFROM node:20-alpine AS productionWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=dev # production deps onlyCOPY --from=builder /app/dist ./dist RUN addgroup -S app && adduser -S app -G appUSER app EXPOSE 8080CMD ["node", "dist/server.js"] # Before multi-stage: ~1.8GB# After multi-stage: ~178MBGo — the most dramatic size reduction:
# Stage 1 — BuildFROM golang:1.21-alpine AS builderWORKDIR /appCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 GOOS=linux go build -o payment-api ./cmd/server # Stage 2 — Final (scratch = empty image)FROM scratchCOPY --from=builder /app/payment-api /payment-apiCOPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/EXPOSE 8080CMD ["/payment-api"] # Before multi-stage: ~400MB (Go toolchain)# After multi-stage: ~8MB (just the binary)Python:
# Stage 1 — Build dependenciesFROM python:3.11-slim AS builderWORKDIR /appCOPY requirements.txt .RUN pip install --user --no-cache-dir -r requirements.txt # Stage 2 — ProductionFROM python:3.11-slim AS productionWORKDIR /appCOPY --from=builder /root/.local /root/.localCOPY . .ENV PATH=/root/.local/bin:$PATHCMD ["python", "app.py"]Naming Stages and Copying Between Them
# Name a stage with ASFROM node:20-alpine AS depsFROM node:20-alpine AS builderFROM node:20-alpine AS production # Copy from a named stageCOPY --from=deps /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./dist # Copy from a specific stage by index (0-based)COPY --from=0 /app/output ./output # Copy from an external imageCOPY --from=nginx:1.25 /etc/nginx/nginx.conf /etc/nginx/nginx.confBuilding Specific Stages
# Build only the builder stage (useful for debugging)docker build --target builder -t payment-api:builder . # Then get a shell inside itdocker run --rm -it payment-api:builder sh# Inspect what was built, check for errors # Build the final production stage (default)docker build -t payment-api:latest . # Compare sizesdocker images | grep payment-api# payment-api builder 1.82GB# payment-api latest 178MBTroubleshooting Reference
| Problem | Cause | Fix |
|---|---|---|
| Files not found in final stage | Wrong path in COPY --from | Check exact output path in builder stage with docker build --target builder |
| Native module errors in Alpine final stage | musl vs glibc mismatch | Use node:20-slim instead of node:20-alpine as final base |
| Final image still large | Copying too much from builder | Only copy what is needed: COPY --from=builder /app/dist ./dist not COPY --from=builder /app . |
| Build fails on multi-platform | Architecture mismatch between stages | Use --platform=$BUILDPLATFORM on builder stage |
TipUse
docker build --target builder -t debug-build .to build and inspect the builder stage when your build is failing. You get a full shell inside the build environment with all dev tools available, making it easy to diagnose compilation or dependency errors.
RememberIn a multi-stage build, only the last
FROMstage becomes the final image. All previous stages are discarded — they exist only as intermediate build environments. The intermediate stages are cached but not pushed to the registry.
Common MistakeCopying the entire
/appdirectory from the builder stage instead of only the build output.COPY --from=builder /app .copies source code, node_modules, temp files, and everything else. Always copy only the specific output directory:COPY --from=builder /app/dist ./dist.
Frequently Asked Questions
What problem does a multi-stage Dockerfile actually solve that a single-stage build can't?
A single-stage build bakes the compiler, package manager caches, dev dependencies, and test tooling into the final image alongside the app, often ballooning a 50MB runtime into a 1GB+ image and expanding the attack surface unnecessarily. Multi-stage builds use one `FROM` stage to compile or build the artifact, then `COPY --from=<stage>` only the compiled output into a fresh, minimal final stage (often based on `alpine` or `distroless`), discarding everything else.
What's a common mistake teams make when writing multi-stage Dockerfiles?
Copying more than necessary into the final stage — like an entire `node_modules` directory instead of just the built `dist` folder — quietly reintroduces the bloat multi-stage builds are meant to eliminate. Another common gotcha is forgetting that each stage has its own filesystem and environment variables set in an earlier stage don't automatically carry over to the next one; they need to be redeclared with `ARG`/`ENV` in the stage that needs them.