Skip to Content

Multi-Stage Builds

When to use

Compile a binary in one stage, copy only the binary into a tiny final image without compilers, headers, or source.

Analogy

multi-stage is making a dish in a big kitchen, then packing only the meal into a lunchbox - the cutting boards and ovens stay home.

Data-flow diagram

  Stage 1 (builder):             Stage 2 (runtime):
    FROM golang:1.22                 FROM gcr.io/distroless/static
    RUN go build -o /app             COPY --from=builder /app /app
                                      CMD ["/app"]
    image size: 1.2GB                image size: 12MB
    (compiler, source)               (only binary, no shell)

Deep explanation

Multi-stage builds let you do everything in stage builder then COPY --from=builder only the artix to a slim final stage. Smaller attack surface, faster pulls, less disk. Use a minimal runtime base (alpine, distroless, scratch). For interpreted languages (Python, Node), still extract wheels or node_modules only, removing build tools.

Examples

Example 1

FROM python:3.12 AS builder
RUN pip wheel --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
COPY --from=builder /wheels /wheels
RUN pip install --no-index --find-links=/wheels /wheels/*
COPY . /app

the second stage installs only run-time deps; build-only wheels aren’t shipped; final image ~150MB instead of ~400MB.

Example 2

FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/node_modules /app/node_modules
CMD ["node", "app.js"]

slim runtime with only the production build; devDeps are in the builder stage only.

Example 3

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /out
FROM mcr.microsoft.com/dotnet/aspnet:8.0
COPY --from=build /out /app
CMD ["dotnet", "app.dll"]

.NET pattern: SDK huge (~1GB), aspnet base small (~150MB); final image just has published output.

Common mistake

Forgetting --from=builder and accidentally copying from the wrong stage. Using scratch and forgetting to put a CA bundle in.

Key takeaway

multi-stage for any compiled language; for interpreted, still extract wheels or node_modules; use named stages for clarity; tag the cache from each stage for fast CI.

Production Failure Playbook

Failure scenario 1: secret-leaked-in-image

Failure scenario 2: hundreds-of-vulnerabilities