Multi-Stage Build Optimization

A Go build that shrinks an 800MB toolchain image to under 10MB, plus BuildKit cache mounts and layer-caching tricks.

Picking up where Images and Dockerfiles left off

Images and Dockerfiles introduced multi-stage builds with a Node.js example and covered the basic rule of ordering instructions from least- to most-frequently-changing. This page pushes both ideas further: a build that shrinks a final image by well over 90%, and caching tricks beyond simple instruction ordering.

A dramatic size reduction: compiling Go

Go is a good example precisely because it makes the payoff obvious — a Go toolchain image is large, but a compiled Go binary has no runtime dependencies at all, so the final image can be almost nothing but that one file.

Dockerfile
# ---- Stage 1: build ----
FROM golang:1.22 AS build

WORKDIR /src

# Cache dependency downloads separately from source changes, same principle
# as npm ci before COPY . . in the Node example.
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# CGO_ENABLED=0 produces a fully static binary with no dynamic library
# dependencies at all — this is what makes FROM scratch possible below.
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server

# ---- Stage 2: runtime ----
FROM scratch

# scratch has no shell, no package manager, no libc — not even a
# certificate store, so TLS-verifying HTTP clients need this copied in.
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]
Bash
$ docker images
REPOSITORY   TAG      IMAGE ID       SIZE
golang       1.22     a1b2c3d4e5f6   839MB     <- the build stage's base image
my-go-app    1.0      f6e5d4c3b2a1   9.8MB     <- the actual shipped image

The golang:1.22 image — the full compiler toolchain — never appears in the final image at all; only the one compiled binary (and a certificate bundle) gets copied across the COPY --from=build boundary. Nearly 840MB of build tooling compresses down to under 10MB of what actually needs to run in production, which is the entire point of a multi-stage build: pay the size cost of your toolchain only during the build stage, never in what you ship and deploy.

FROM scratch is the extreme end of this — it works only because CGO_ENABLED=0 produces a statically linked binary with zero runtime dependencies. An interpreted or JIT'd language (Node, Python, Java) can't go this far, since the language runtime itself has to exist somewhere in the final image; for those, a distroless or alpine-based runtime stage (as in the security best practices page) is the equivalent move.

Layer caching order tricks beyond "deps before source"

Each RUN becomes its own layer. For package manager operations specifically, splitting install and cleanup across multiple RUN instructions bloats the image, because a layer only ever grows — deleting a file in a later layer doesn't shrink the earlier layer that added it, it just hides it from the final filesystem view while the bytes remain in the image itself:

Dockerfile
# Wasteful: the apt cache added in the first RUN is still physically
# present in the image, even though the second RUN "deletes" it —
# it just no longer appears in the final merged filesystem view.
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
Dockerfile
# Correct: install and cleanup happen within the SAME layer, so the
# cache never gets baked into the image in the first place.
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

BuildKit cache mounts for package managers

A cache mount is different from a layer cache: it persists a directory across builds, without that directory ever becoming part of any image layer at all — ideal for a package manager's download cache, which should survive between builds but should never actually ship in the image.

Dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS build

WORKDIR /app
COPY package.json package-lock.json ./

# npm's cache persists across builds via the mount, but never becomes
# part of any image layer — smaller images, faster repeat installs.
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build

This solves a real gap that plain layer caching leaves: layer caching only helps when package.json/package-lock.json are unchanged between builds. The moment a single dependency version bumps, the whole npm ci layer is invalidated and reinstalls everything from the network. A cache mount keeps npm's own download cache warm across builds regardless of whether the lockfile changed, so even a full reinstall mostly reads from local cache instead of re-downloading every package.

Order multi-stage copies to maximize reuse

When a build stage produces several distinct outputs, copying the most stable one first (and the most frequently changing one last) applies the same "stable things first" principle across stage boundaries, not just within one stage:

Dockerfile
FROM node:20-alpine AS runtime
WORKDIR /app

# node_modules changes only when dependencies change
COPY --from=build /app/node_modules ./node_modules

# compiled output changes on nearly every build
COPY --from=build /app/dist ./dist

Comparing the impact

Technique What it improves Typical effect
Multi-stage build (toolchain vs. runtime stage) Final image size, attack surface Can cut image size by 80-95%+
Dependency-manifest-first COPY ordering Build speed on source-only changes Skips dependency reinstall entirely when deps are unchanged
Combining install + cleanup in one RUN Final image size Removes package-manager cache/temp files from the image permanently
BuildKit cache mount Build speed on dependency changes Reinstalls read from a persistent local cache instead of the network
FROM scratch / distroless final stage Image size, attack surface Smallest possible runtime image, for statically-linked or minimal-runtime languages

Common mistakes

  • Believing that deleting a file in a later RUN instruction shrinks the image — it doesn't; the earlier layer that added the file still contains it, and only combining install-and-cleanup into a single RUN actually avoids that cost.
  • Reaching for FROM scratch for a language runtime (Node, Python, a JVM app) that genuinely needs its runtime present in the final image — this only works for statically-linked binaries with no runtime dependencies.
  • Forgetting the certificate bundle when using FROM scratch for a binary that makes outbound HTTPS calls — with no OS at all, there's no default trust store, and TLS verification fails until ca-certificates.crt is copied in explicitly.
  • Not enabling BuildKit cache mounts and wondering why every dependency reinstall re-downloads the entire package set from the network, even in CI where builds run frequently back-to-back.
  • Copying the entire build stage's working directory into the runtime stage (COPY --from=build /app /app) instead of just the specific compiled artifact needed — this silently drags source files, dev dependencies, and build tooling back into the image the multi-stage build was supposed to exclude.