Docker Multi-Stage Builds and Layer Caching
Concept
A naive Dockerfile builds everything — compilers, dev dependencies, source, build artifacts — into a single image, and every one of those layers ships to production even though only the final runtime artifact is actually needed there. Two independent Docker features fix this, and they compound when used together.
Multi-stage builds let a single Dockerfile declare more than one FROM line, each starting a fresh, independent stage. An early stage can install a full compiler toolchain and build the application; a later stage starts from a minimal runtime base image (e.g. alpine, distroless) and uses COPY --from=<stage> to pull in only the compiled binary or built assets. Everything else — the compiler, the node_modules dev dependencies, the source tree — stays behind in the discarded intermediate stage and never reaches the final image.
Layer caching is orthogonal: every instruction in a Dockerfile produces a layer, and Docker reuses a cached layer on a rebuild if that instruction and its build context are unchanged. The moment one instruction's cache invalidates, every instruction after it in that stage must re-run too — cache invalidation cascades forward, never backward. This makes instruction order a real design decision: put the instructions least likely to change (installing a pinned dependency list) before the ones most likely to change (copying application source).
Combined, the standard pattern for a compiled or bundled app looks like:
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # cached until the lockfile changes
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
The dependency-install layer is cached across every rebuild that only touches source files, and the final image never contains the build stage's compiler or dev-only packages.
Tradeoffs
| Approach | Final image size | Rebuild speed | Dockerfile complexity |
|---|---|---|---|
| Single stage, no cache-aware ordering | Bloated — build tools and dev deps ship to prod | Slow — any source change reinstalls everything if COPY . . precedes install |
Lowest |
| Single stage, cache-aware ordering | Still bloated | Fast on unchanged dependencies | Low |
| Multi-stage + cache-aware ordering | Minimal — only runtime artifacts ship | Fast, and only the changed stage re-executes | Higher — more stages to reason about and name |
Cache-aware ordering alone gets most of the rebuild-speed win; multi-stage is what actually shrinks the shipped image and its attack surface. They solve different problems and are usually worth combining, but a one-off script with no compile step and few dependencies may not need either.
When to use / when not to
- Use whenever the build-time toolchain differs meaningfully from the runtime — compiled languages (Go, Rust, Java, C++), or any frontend build step (Vite/Webpack) that produces static assets served by a much smaller server image.
- Especially valuable in CI, where image pull/push time and registry storage cost scale directly with image size, and where cache hits turn a multi-minute build into a sub-minute one.
- Less necessary for already-minimal interpreted-language images with few dependencies (a small Python script with two pinned packages) — cache-aware layer ordering still helps, but a second stage adds ceremony without much to strip out.
- Don't reach for extra stages purely for "best practice" if the final image is already small and the Dockerfile is simple; added stages are a maintenance cost too.
Common pitfall
Copying the entire build context (COPY . .) before installing dependencies. Since a RUN npm ci (or pip install, go mod download) that comes after a COPY . . sees its cache invalidated on every source change — even a one-line README edit — because Docker's cache key includes the copied files. The fix is mechanical: copy only the dependency manifest first, install, then copy the rest of the source. Getting this ordering backwards is the single most common reason a team's "we use Docker caching" claim doesn't actually save any build time.
Engineering Lens
The underlying discipline — order operations from least-to-most-frequently-changing so a cache actually gets hit — isn't Docker-specific. The same reasoning applies to CI pipeline caching (restore a dependency cache keyed on the lockfile hash, not the whole repo hash) and to build systems generally (Bazel, Nix). Once you've internalized why COPY package.json has to come before COPY ., the same "what changes least often should sit earliest" instinct transfers directly to designing any other layered cache.