Skip to main content

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.

◈ DIAGRAM
+------------------------------------------+
| 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:

Dockerfile
# syntax=docker/dockerfile:1
# Stage 1 — Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # all deps including devDependencies
COPY . .
RUN npm run build # TypeScript -> dist/
# Stage 2 — Production
FROM node:20-alpine AS production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev # production deps only
COPY --from=builder /app/dist ./dist
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 8080
CMD ["node", "dist/server.js"]
# Before multi-stage: ~1.8GB
# After multi-stage: ~178MB

Go — the most dramatic size reduction:

Dockerfile
# Stage 1 — Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o payment-api ./cmd/server
# Stage 2 — Final (scratch = empty image)
FROM scratch
COPY --from=builder /app/payment-api /payment-api
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
CMD ["/payment-api"]
# Before multi-stage: ~400MB (Go toolchain)
# After multi-stage: ~8MB (just the binary)

Python:

Dockerfile
# Stage 1 — Build dependencies
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage 2 — Production
FROM python:3.11-slim AS production
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]

Naming Stages and Copying Between Them

Dockerfile
# Name a stage with AS
FROM node:20-alpine AS deps
FROM node:20-alpine AS builder
FROM node:20-alpine AS production
# Copy from a named stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Copy from a specific stage by index (0-based)
COPY --from=0 /app/output ./output
# Copy from an external image
COPY --from=nginx:1.25 /etc/nginx/nginx.conf /etc/nginx/nginx.conf

Building Specific Stages

Bash
# Build only the builder stage (useful for debugging)
docker build --target builder -t payment-api:builder .
# Then get a shell inside it
docker 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 sizes
docker images | grep payment-api
# payment-api builder 1.82GB
# payment-api latest 178MB

Troubleshooting 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
Tip

Use 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.

Remember

In a multi-stage build, only the last FROM stage 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 Mistake

Copying the entire /app directory 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.