Skip to content
6 min read·Lesson 6 of 10

Multi-Stage Builds and Image Optimization

Use multi-stage builds to separate build-time tooling from runtime, dramatically shrinking final images and reducing the attack surface.

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

BaseSizeNotes
scratch0 bytesEmpty. Only works for fully static binaries (Go, Rust).
distroless/static~2 MBNo shell, no package manager. Great for Go/Rust.
distroless/base~20 MBAdds glibc, libssl, libcrypto. For dynamically-linked binaries.
alpine~5 MBHas a shell. Uses musl libc — sometimes incompatible with glibc binaries.
debian-slim / ubuntu~30–80 MBFamiliar, 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.

Key Takeaways

  • Multi-stage builds let you compile in one stage and copy only the artefacts to a tiny final stage.
  • Final images can shrink from 1 GB to under 100 MB by excluding compilers and build tools.
  • Use distroless or alpine images for the final stage to minimise attack surface.
  • BuildKit (the default in modern Docker) parallelises stages and supports --mount=type=cache for faster rebuilds.
  • Smaller images deploy faster, autoscale faster, and are cheaper to store and transfer.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →