If you naively containerise a compiled application, you end up shipping the entire build toolchain — gcc, the JDK, npm, your test fixtures — alongside the few megabytes of binary that actually need to run. Multi-stage builds solve this elegantly.
The Problem
Consider a simple Go web server:
FROM golang:1.22
WORKDIR /src
COPY . .
RUN go build -o app .
CMD ["./app"]
This works, but the resulting image is over 800 MB — most of it is the Go compiler and standard library that you don't need to run the binary, only to build it.
The Multi-Stage Solution
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /out/app /app
USER nonroot
ENTRYPOINT ["/app"]
The first stage compiles the binary. The second stage starts from distroless (a Google-maintained image that contains essentially nothing — no shell, no package manager) and copies only the binary across. The final image is around 10 MB.
Each FROM starts a new stage. Anything not copied into the final stage is discarded.
Patterns by Language
Node.js
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
USER node
CMD ["node", "dist/server.js"]
Java (Spring Boot)
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY . .
RUN ./mvnw -DskipTests package
FROM eclipse-temurin:21-jre
COPY --from=build /src/target/*.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
Python
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
Final-Stage Base Image Choices
| Base | Size | Notes |
|---|---|---|
scratch | 0 bytes | Empty. Only works for fully static binaries (Go, Rust). |
distroless/static | ~2 MB | No shell, no package manager. Great for Go/Rust. |
distroless/base | ~20 MB | Adds glibc, libssl, libcrypto. For dynamically-linked binaries. |
alpine | ~5 MB | Has a shell. Uses musl libc — sometimes incompatible with glibc binaries. |
debian-slim / ubuntu | ~30–80 MB | Familiar, broad compatibility, larger attack surface. |
BuildKit and Cache Mounts
Modern Docker uses BuildKit by default. It parallelises independent stages and supports cache mounts that persist between builds without becoming part of the image:
# syntax=docker/dockerfile:1.7
FROM rust:1.78 AS build
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo build --release
The cargo registry and target directory survive across builds, dramatically speeding up incremental rebuilds — without bloating the final image.
Why Smaller Images Matter
- Faster deploys: a 30 MB image pulls in 1 second; a 1 GB image takes 30+ seconds per node.
- Faster autoscaling: cold-start latency in serverless and Kubernetes is dominated by image pull time.
- Lower cost: registry storage and egress bandwidth are billed.
- Smaller attack surface: no compiler, no shell, no package manager means fewer CVEs and fewer ways for an attacker to escalate.
- Faster CI: every PR build pulls and pushes images. Multiply by frequency.