Multi-Stage Builds and Small Images

Build with a compiler, ship without one. How multi-stage builds separate the build environment from the runtime image, and the BuildKit features that make builds fast as well as small.

intermediate 20 min lesson hands-on task included

You need Maven, or the Go compiler, or the whole npm dependency tree to build your application. You need none of it to run the result. A single-stage image ships all of it anyway — the compiler, the source, the build cache, and often the credentials that fetched the dependencies.

Multi-stage builds are the fix, and they are one instruction.


Topic 1: The Problem, Concretely

Build a Java web application the obvious way and you get this:

FROM maven:3.9-eclipse-temurin-21
RUN git clone https://github.com/acme/game-of-life.git
RUN cd /game-of-life && mvn package
EXPOSE 8080
CMD ["java", "-jar", "/game-of-life/target/app.jar"]

That image is around 700 MB. Of that, roughly 40 MB is your application. The rest is Maven, the JDK, the ~/.m2 repository full of downloaded dependencies, and a git checkout of your source.

Every one of those is a liability:

  • Size. Every pull, every node, every deploy pays for it.
  • Attack surface. A compiler, a package manager and a git client inside a production container are exactly the tools an attacker wants.
  • Source disclosure. Your source tree is in the shipped image.
  • Credential leakage. Whatever token fetched the private dependencies is somewhere in those layers.

The pre-multi-stage workaround was to build outside Docker in CI and COPY the artefact in. That works, but it moves the build environment out of the Dockerfile and back into “whatever the CI agent happens to have installed” — the exact problem containers were meant to solve.


Topic 2: Multi-Stage, In One Instruction

Use several FROM lines. Name the stages. Copy artefacts forward with COPY --from=.

# ---- build stage: has the whole toolchain ----
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /src
COPY pom.xml .
RUN mvn -B dependency:go-offline            # cached unless pom.xml changes
COPY src ./src
RUN mvn -B package -DskipTests

# ---- runtime stage: has a JRE and nothing else ----
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S -G app app
COPY --from=builder --chown=app:app /src/target/app.jar /app/app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Only the last stage becomes the image. Everything in builder — Maven, the JDK, the .m2 cache, the source — is discarded. The final image is the JRE base plus one jar: roughly 190 MB, and the drop is larger still for compiled languages.

Note the dependency-caching trick in the build stage. COPY pom.xml and resolve dependencies before COPY src. A source change re-runs only the last build step; a dependency change re-runs both. Same principle as lesson 6, applied inside the builder.

The Go version makes the point even more starkly:

FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server ./cmd/server

FROM gcr.io/distroless/static-nonroot
COPY --from=builder /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Roughly 900 MB down to roughly 12 MB, with no shell, no package manager and no libc in the shipped image.


Topic 3: Things Multi-Stage Lets You Do

Copy from an external image, not just a previous stage:

COPY --from=nginx:1.27-alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=ghcr.io/acme/certs:2024 /certs/ca.pem /etc/ssl/certs/

Stop at a stage — useful for a test image or for debugging the builder:

docker build --target builder -t app:build .
docker run --rm -it app:build sh          # poke around inside the build environment

Fan out from a common base so build and test share the setup:

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS test
COPY . .
RUN npm run lint && npm test

FROM deps AS build
COPY . .
RUN npm run build

FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html

BuildKit builds independent stages in parallel, so test and build here overlap rather than queue.


Topic 4: Choosing a Base Image

Size is not the only criterion, but it is the easiest to measure and the one most often ignored. The classic illustration: Alpine at ~5 MB against Fedora at ~205 MB, for a base that in many cases does the same job.

BaseTypical sizeTrade-off
ubuntu / debian70–120 MBFamiliar, glibc, everything works, largest
*-slim variants30–80 MBDebian minus docs, locales and dev headers. Excellent default
alpine5–10 MBmusl libc, not glibc — see below
distroless2–20 MBRuntime only. No shell, no package manager
scratch0Empty. Static binaries only

The Alpine caveat is real. Alpine uses musl rather than glibc. Most software is fine; some is not. Python wheels compiled for glibc do not work, so pip falls back to building from source and your image gets slower to build and sometimes bigger than the Debian slim equivalent. DNS resolution behaviour differs in edge cases. Some Java and Node native modules misbehave. Test rather than assume — python:3.12-slim is frequently the better choice over python:3.12-alpine.

Distroless and scratch are excellent for security: no shell means no shell for an attacker either. They also mean no docker exec ... sh for you. The debugging answer is to join the container’s namespaces from a separate tools container (--net=container:, --pid=container: from lesson 4), which is worth practising before you need it at 3am.


Topic 5: BuildKit

BuildKit is the modern builder and it is the default in current Docker. It gives you parallel stages, better caching, and two features worth adopting deliberately.

Cache mounts keep a package cache across builds without ever putting it in a layer:

# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

The pip cache persists between builds on the same machine and is not committed to the image. Same pattern works for /var/cache/apt, ~/.m2, ~/.npm and Go’s module cache.

Build secrets mount a secret for one instruction only, leaving no trace in any layer:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc -t app:1.0 .

Compare with the wrong ways: ARG NPM_TOKEN is visible in docker history, and COPY .npmrc then RUN rm .npmrc leaves the file recoverable in an earlier layer (lesson 5). Build secrets are the only correct answer.

Multi-platform builds are also BuildKit’s job, which matters now that ARM servers and Apple Silicon are ordinary:

docker buildx build --platform linux/amd64,linux/arm64 -t acme/api:1.0 --push .

Topic 6: Measuring, Not Guessing

docker images acme/api                                # size, at a glance
docker history acme/api:1.0                           # which instruction cost what
docker history --no-trunc acme/api:1.0 | head -20     # the full commands
docker image inspect acme/api:1.0 --format '{{len .RootFS.Layers}}'

docker history sorted mentally by size will name your problem in seconds. The usual culprits, in order of frequency:

  1. A package manager cache never removed in the same RUN.
  2. COPY . . with no .dockerignore, hauling in .git and node_modules.
  3. A full JDK/SDK where the runtime would do.
  4. Build tooling that a multi-stage build would have dropped.
  5. A RUN rm in a later layer that saved nothing (lesson 5).

Set a size budget and treat regressions like test failures. An image that grows 200 MB in one PR is telling you something about that PR.

Try it yourself: Build the same Go or Java application three ways — full toolchain base, multi-stage onto a slim runtime, multi-stage onto distroless. Put the three sizes in a table. Then run docker run --rm <image> sh against each; the third will fail, and understanding why it fails is the security benefit made tangible.

Common mistake: Assuming smaller is always better and reaching for Alpine reflexively. If your build spends four minutes compiling C extensions that would have been a pre-built wheel on Debian, you traded build time and reliability for 40 MB. Measure both numbers before deciding.