Skip to content
RungsySign in

Layers & Multi-Stage Builds

Cache-friendly ordering and a final image that isn't 1.2GB.

35 minDifficulty 3/5docker · performanceAI-writtenWritten by a model on 16 August 2026 and not yet read by a person. Checked automatically: schema, the pedagogical rules the hand-written material is held to, and every diagram parsed for real.

Before this

Why this exists

A Dockerfile that copies the entire application THEN installs dependencies rebuilds and reinstalls every single npm package from scratch on every code change — even a one-line comment edit invalidates the dependency install, turning a 2-second rebuild into a 3-minute one. Ordering instructions correctly, so cache-friendly steps come before frequently-changing ones, is the difference between an instant rebuild and a painfully slow one.

The mental model

Each instruction in a Dockerfile produces a separate, cached LAYER — and Docker reuses a cached layer from a previous build if the instruction AND everything before it are unchanged. The moment one layer changes, EVERY layer after it must be rebuilt too, even if those later instructions themselves didn't change — cache invalidation cascades forward, never backward.

How it works

Order matters: put what changes least often first

`COPY package.json .` followed by `RUN npm install` followed by `COPY . .` means dependency installation is cached and REUSED as long as `package.json` itself hasn't changed — application code changes (which happen far more often than dependency changes) only invalidate the final `COPY . .` layer and whatever comes after, leaving the expensive `npm install` step cached.

Multi-stage builds separate what's needed to BUILD from what's needed to RUN

A `FROM node:20 AS builder` stage can include the full toolchain (TypeScript compiler, build tools, dev dependencies) needed to produce a compiled application. A SEPARATE final stage (`FROM node:20-slim`) copies ONLY the compiled output from the builder stage, discarding the entire build toolchain from the final image — producing a dramatically smaller image with none of the build-time-only tooling shipped to production.

A .dockerignore file prevents unnecessary cache invalidation and bloat

Without a `.dockerignore`, `COPY . .` includes `node_modules`, `.git`, and log files — bloating the image and, worse, causing that layer to invalidate on changes to files that have nothing to do with the actual application (a `.git` commit, a stray log file). Excluding these explicitly keeps the copied context limited to what actually matters for the build.

Layer count and size both matter, but for different reasons

Each layer adds a small amount of metadata overhead, so combining trivially related commands (`RUN apt-get update && apt-get install -y curl` in one layer, rather than two separate `RUN` instructions) reduces layer count. Layer SIZE matters separately — a layer that installs a large package and then deletes temporary files in a SEPARATE later instruction still has that large data baked into the earlier layer, since layers are immutable once created; cleanup has to happen within the SAME instruction to actually reduce image size.

The mechanism

Docker builds each instruction into a layer and caches it, keyed on the instruction's content plus the state of everything before it. On a rebuild, Docker walks the instructions from the top, reusing each cached layer as long as it and everything preceding it are unchanged. The moment an instruction's inputs differ from the cached version, that layer and every subsequent one must be rebuilt fresh, regardless of whether those later instructions themselves changed.

flowchart TD
  L1[FROM node:20] --> L2[COPY package.json .]
  L2 --> L3[RUN npm install]
  L3 --> L4[COPY . .]
  L4 --> L5[CMD node server.js]
  Note1[Code change only invalidates L4, L5\nL1-L3 stay cached]
Diagram source for Layers & Multi-Stage Builds.

What people get wrong

The order of instructions in a Dockerfile doesn't matter, as long as they're all there.
Order directly determines caching behaviour — putting frequently-changing instructions (like copying application code) BEFORE rarely-changing ones (like installing dependencies) invalidates the cache for everything after the frequent change, forcing unnecessary rebuilds of things that didn't actually need to change. This is one of the most impactful, easy-to-fix Dockerfile mistakes — reordering just two lines can turn a multi-minute rebuild into a near-instant one, with zero change to the actual final image content.
Deleting temporary files in a later RUN instruction removes them from the final image's size.
Layers are immutable once created — data added in an earlier layer remains part of the image's total size even if a LATER layer deletes those files, because the earlier layer's content doesn't change; only combining the install-and-cleanup into a SINGLE RUN instruction actually avoids baking in the temporary data. This surprises people who 'clean up' in a separate step expecting the final image to shrink, when the deleted files are still fully present in an earlier, already-committed layer.
Multi-stage builds are only useful for compiled languages that need a separate build step.
Multi-stage builds are equally useful for JavaScript/TypeScript projects, or any case where BUILD-time tooling (dev dependencies, compilers, test runners) differs from what's actually needed at RUNTIME — separating them keeps the final image lean regardless of language. Limiting the mental model to 'only for compiled languages' misses a huge, common use case: excluding devDependencies and build tools from a Node.js production image.

When not to use it

The Dockerfile has only a couple of simple, rarely-changing instructions, with no meaningful build/runtime distinction.
A single-stage Dockerfile is simpler and entirely appropriate — multi-stage builds add complexity that's only worth it when there's a genuine build-vs-runtime tooling difference to separate.
You need every rebuild to reflect the absolute latest state with zero caching ambiguity, for a critical release build.
`docker build --no-cache` explicitly disables layer caching for that specific build, guaranteeing every instruction re-executes fresh, at the cost of a slower build.

Terms

Layer
A cached, incremental filesystem change produced by one Dockerfile instruction, reused across builds if its inputs are unchanged.
Cache invalidation (Docker)
The point at which a layer's inputs differ from a previous build, forcing that layer and every subsequent one to rebuild fresh.
Multi-stage build
A Dockerfile pattern using multiple FROM statements to separate build-time tooling from the final runtime image, keeping the final image lean.
.dockerignore
A file listing paths to exclude from the build context, preventing unnecessary files (node_modules, .git) from bloating the image or invalidating cache.

In an interview

Why does copying package.json and running npm install BEFORE copying the rest of the application code speed up rebuilds?

  • Docker caches each instruction's layer, reusing it if its inputs (and everything before it) are unchanged
  • if package.json hasn't changed, the npm install layer is reused from cache, skipping the (often slow) dependency installation entirely
  • only the later COPY of application code, which changes far more often, gets invalidated — not the expensive dependency install step

Can you recall it?

Why does changing a Dockerfile instruction invalidate not just that layer, but every layer after it, even ones that didn't themselves change?

Keep track of this

Add Containers to your map and Rungsy will schedule reviews so you actually remember it.