09 — Building Images: Dockerfiles, Layer Caching, and Multi-Stage Builds
Every code change triggered a five-minute rebuild, and I couldn't see why until I found the rule that governs almost all build performance: every Dockerfile instruction is a cached layer, and a layer is reused only if nothing above it changed — so the order of instructions determines whether a one-line code change rebuilds the whole image or just the top slice. [1][2]
The framing worth holding onto is that building images is two problems layered on top of each other. The first is correctness — does the Dockerfile produce an image that runs the app. The second is efficiency — does it rebuild fast, and is the resulting image small. The same instruction ordering solves both, because the cache and the size both flow from how I structure my layers [1][3].
Layers and the cache
Every instruction in a Dockerfile (FROM, RUN, COPY, ADD, etc.) creates one new layer in the image [1][3]. When I rebuild, Docker walks the file top to bottom and, for each instruction, checks whether its inputs match the cached version. If they match, it reuses the cached layer and skips the work. The moment it finds an instruction whose inputs have changed, it rebuilds that layer — and every layer below it [2].
The implication is the whole game. Consider two Dockerfiles that install dependencies then copy source:
# BAD — code change busts the dependency cache
FROM node:20
WORKDIR /app
COPY . . # any source change invalidates everything below
RUN npm ci # re-installs 800 packages on every code change
CMD ["node", "server.js"]# GOOD — dependencies cached unless package.json changes
FROM node:20
WORKDIR /app
COPY package*.json ./ # only re-runs npm ci when package.json changes
RUN npm ci
COPY . . # source changes only bust from here up
CMD ["node", "server.js"]The difference is enormous. In the bad version, editing one line of server.js changes the input to COPY . ., which busts npm ci, so every rebuild reinstalls every dependency. In the good version, package.json rarely changes, so npm ci is cached almost forever, and source edits only invalidate the thin top layer [2][3]. The rule, stated plainly: copy the thing that changes least often first; copy the thing that changes most often last.
What invalidates a layer
The cache key for each instruction depends on the instruction text and its inputs [2]:
- RUN <command> — keyed on the command string. Same string, cached.
- COPY src dst — keyed on the checksums of the source files. Change a file, bust the layer.
- ADD — same as COPY, plus URL/archive handling.
- Anything ARG or ENV used in a later instruction — changing the variable busts everything downstream.
This is why pinning versions in RUN apt-get install -y curl=7.88.1-1 matters in two directions: it makes builds reproducible and it makes the cache key stable, so an unrelated change doesn't re-trigger the install.
Reducing image size
The same layer-thinking that speeds up builds also shrinks images. Every layer is permanent — even if a later layer deletes a file an earlier layer added, the earlier layer still carries those bytes in the image's history [3][4]. So the strategies all aim to avoid bloating layers in the first place:
- Use minimal base images. node:20-alpine is tens of megabytes; node:20 is hundreds. Alpine uses musl instead of glibc, which occasionally breaks native modules, but for most apps the size win is worth checking.
- Combine cleanup into the same RUN. Because layers are additive, RUN apt-get install -y curl followed by RUN rm -rf /var/lib/apt/lists/* still ships the apt cache in the first layer. The cleanup has to be in the same RUN, chained with &&, so the bloat never lands in a committed layer [3]:
``dockerfile RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* ``
- Multi-stage builds — the big one.
Multi-stage builds: build fat, ship thin
The most powerful size-reduction technique is the multi-stage build: use a heavy "builder" stage with all the compilers and build tools, then copy only the produced artifact into a tiny final image [4]. The build tools never make it into the image I ship.
# stage 1 — builder, has the compiler/toolchain
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
# stage 2 — final, has only the binary
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/server /server
ENTRYPOINT ["/server"]The builder stage is hundreds of megabytes (the Go toolchain, the source, module cache). The final image contains only the compiled binary — single-digit megabytes — because COPY --from=builder pulls in just the artifact, not the stage that built it [4]. The same pattern works for any compiled language: a Node app's final stage can be node:20-alpine carrying just dist/ and node_modules/, without the dev dependencies or TypeScript used to build them.
The final image is small, has no compiler for an attacker to abuse, and starts fast because there's nothing in it to load. Multi-stage is the single highest-leverage change I make to any real Dockerfile [4].
Buildx and the modern builder
The default docker build has been quietly replaced under the hood by BuildKit via the buildx CLI, and it's worth knowing because it unlocks the features above plus more: parallel building of independent stages, better caching (including remote cache export/import between machines), and multi-platform builds (one command produces an image that runs on both amd64 and arm64) [1]. The syntax is unchanged — docker build invokes BuildKit by default in current Docker — but docker buildx build exposes the advanced flags when I need them.
How I use this
The Dockerfile template I default to encodes everything above. Dependencies copied and installed before source. RUN steps chained so cleanup shares the layer. A multi-stage build the moment the app is compiled or has dev-only tooling. Versions pinned in RUN installs for reproducibility and stable cache keys. When a build feels slow, I run docker build --progress=plain to watch which layers are CACHED and which rebuild — the first non-cached line is the cache-busting instruction I need to reorder. Treating the Dockerfile as a cache-and-size optimization problem, not just a correctness one, is what made my image builds tolerable.
References
[1] Docker, Inc., "Docker Build — Overview," Docker Docs, 2024. [Online]. Available: https://docs.docker.com/build/concepts/overview
[2] Docker, Inc., "Docker Build cache," Docker Docs, 2024. [Online]. Available: https://docs.docker.com/build/cache/
[3] Docker, Inc., "Dockerfile best practices," Docker Docs, 2024. [Online]. Available: https://docs.docker.com/develop/develop-images/dockerfile_best-practices/
[4] Docker, Inc., "Multi-stage builds," Docker Docs, 2024. [Online]. Available: https://docs.docker.com/build/building/multi-stage/
[5] Docker, Inc., "Dockerfile reference," Docker Docs, 2024. [Online]. Available: https://docs.docker.com/engine/reference/builder/
[6] Docker, Inc., "docker buildx build reference," Docker Docs, 2024. [Online]. Available: https://docs.docker.com/reference/cli/docker/buildx/build/
Knowledge check · Question 1 of 5
Why does the "bad" Dockerfile (COPY . . before RUN npm ci) reinstall all dependencies on every code change?
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!