02-building-images

Multi-Stage Builds and Choosing a Base Image

How a 2.2GB image becomes 145MB — separating the build environment from the runtime, and picking a base image with eyes open about the tradeoffs.

September 13, 2026
dockermulti-stagedistrolessalpinebase-imagesimage-sizedigest-pinning

What's Actually in Your 2GB Image

Run docker history on a bloated image and the story is nearly always the same. The application is a few megabytes. Everything else is machinery used to produce it and never needed again:

  • A compiler toolchain, headers, and build-essential packages
  • Dev dependencies — test frameworks, linters, type definitions
  • Package manager caches
  • Source files that were compiled into something else
  • Sometimes a .git directory that slipped past a missing .dockerignore

None of this runs in production. It's all shipped anyway, because the default Dockerfile builds and runs in the same place.

You already know from the layers guide that you can't fix this by deleting things at the end — the bytes stay in earlier layers. The fix is structural: build in one place, run in another.

Two Stages

dockerfile
# syntax=docker/dockerfile:1
 
# ---- Stage 1: build ----
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
 
# ---- Stage 2: runtime ----
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

The mechanism is a single instruction: COPY --from=build. The final image starts fresh from node:22-slim and receives only the two directories that were named. Everything in the build stage — the full Node image, the compiler, the dev dependencies, the source tree — is discarded. It never becomes a layer in the result.

For compiled languages the effect is even more dramatic, because the runtime stage may need nothing but the binary:

dockerfile
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
 
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

A Go toolchain image is several hundred megabytes. The result here is roughly the size of the binary.

Stages are also a build-speed tool, not only a size tool. BuildKit builds independent stages in parallel and skips any stage whose output nothing copies from. A Dockerfile with separate stages for compiling assets and compiling the server does both at once.

Targeting a Stage

Name your stages and you can build any one of them directly with --target:

dockerfile
FROM node:22 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
 
FROM deps AS test
COPY . .
RUN npm run lint && npm test
 
FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev
 
FROM node:22-slim AS runtime
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
bash
docker build --target test -t myapp:test .     # CI runs the tests
docker build -t myapp:latest .                 # default: the last stage

One Dockerfile now serves testing and production, with the dependency layer shared between them. This beats maintaining Dockerfile.test alongside Dockerfile and watching them drift.

Check yourself

A Dockerfile has a build stage that installs a 900MB toolchain, and a runtime stage that copies one compiled binary out of it. How large is the final image, roughly?

Choosing a Base Image

The second half of the size question. The options form a spectrum, and every step down trades convenience for surface area.

BaseTypical sizeHas a shell?Good for
debian, ubuntu, node:22300MB–1GBYesBuild stages, debugging, complex native dependencies
-slim variants50–200MBYesA sensible default runtime for most applications
alpine5–50MBYes (busybox)Small images, when your dependencies tolerate musl
distroless2–50MBNoProduction runtimes with a minimal attack surface
scratch0NoFully static binaries (Go, Rust) and nothing else

The Alpine caveat worth knowing

Alpine is genuinely tiny, and it's the reflexive recommendation in a lot of tutorials. It's also the one that most often produces surprises, because it uses musl libc instead of the glibc that most Linux software is built and tested against.

In practice this means:

  • Packages with precompiled native binaries may not have an Alpine build, so they compile from source — slower builds, occasionally failures.
  • Some workloads have shown measurable performance differences under musl's allocator, notably certain Python and JVM ones.
  • DNS resolution behaviour has historically differed in ways that matter in cluster environments.

None of this makes Alpine a bad choice — plenty of production systems run on it happily. It makes it a choice to test, rather than one to adopt because it's small. If you hit strange native-module or performance problems, a -slim Debian variant is often both bigger and less trouble.

Distroless and the debugging tradeoff

Distroless images contain your application's runtime and its dependencies — no shell, no package manager, no ls, no curl. The attack surface is drastically smaller, and so is the set of things an attacker can do after getting code execution.

The cost is real: docker exec -it container sh fails, because there is no sh. The answer isn't to abandon distroless but to know the escape hatch — docker debug attaches a toolbox to a running container, and a --target debug stage built on a fuller base gives you the same application with tools available. The debugging guide in phase 4 covers both.

💡

Docker Hardened Images are worth knowing about here: minimal, continuously patched images that run as non-root by default and ship with signed SBOMs and build provenance. They include distroless variants, and their core features are available under Apache 2.0. They're a reasonable answer when you want a hardened base without maintaining one yourself — the supply-chain guide in phase 5 revisits what the signatures and SBOMs actually buy you.

Pin What You Depend On

FROM node:22-slim is a moving target. The tag is reassigned as patches ship, so the same Dockerfile can produce different images on different days — which is exactly the "works on my machine" problem containers were meant to end.

Tags are convenient. Digests are immutable:

dockerfile
# Moves without warning
FROM node:22-slim
 
# Byte-identical, every time, forever
FROM node:22-slim@sha256:a1b2c3d4e5f6...

The sensible middle ground for most teams: pin digests for anything reproducible or security-relevant, and use a tool that raises a pull request when a newer digest is available — so updates become a reviewed change rather than something that happens silently between two builds.

🚨

FROM node:latest is the worst of both worlds: it can jump a major version overnight and break your build with no change on your side. Pin at minimum to a major version, and preferably to a digest.

Check yourself

A team switches their Python runtime from python:3.12-slim to python:3.12-alpine to save space. The build now takes 6 minutes instead of 40 seconds, and one dependency fails to install. What's the most likely cause?

Putting the Phase Together

A production Dockerfile that uses everything from this phase:

dockerfile
# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
 
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

Dependencies install above the source copy so the cache holds. A cache mount keeps rebuilds cheap. A secret mount supplies the private registry credential without recording it. The runtime stage starts fresh and receives only what it needs, running as a non-root user. Pair it with a .dockerignore and you have covered every failure mode in this phase.

What's Next

Phase 3 shifts from building images to running them: how container networking and DNS actually resolve, when to use a volume versus a bind mount, and what happens between docker stop and a process actually exiting — the source of the mysterious ten-second shutdown.