Docker Layers and Build Cache: How to Write a Dockerfile That Doesn't Rebuild Everything Every Time
Understand how Docker layers and the build cache actually work, then use instruction ordering, multi-stage builds, and BuildKit cache mounts to cut build times from minutes to seconds.
Images Are Built Layer by Layer
A Docker image isn't one big archive — it's built line by line from the instructions in your Dockerfile. Nearly every FROM, COPY, and RUN produces a new, read-only layer, and these layers stack on top of each other to form the final image.
FROM node:20-alpine
# Layer 1: the base image itself
COPY package.json package-lock.json ./
# Layer 2: just the diff from these two files
RUN npm install
# Layer 3: node_modules produced by npm install
COPY . .
# Layer 4: the rest of the project's source codeThese four layers are independent of each other, and that's exactly what lets Docker check each one separately: has anything changed in this layer since the last build?
The Cache Rule: Top to Bottom, One Miss Invalidates Everything Below It
When Docker builds an image, it walks through the Dockerfile's instructions in order. If a layer matches what's already in the cache — same instruction, same underlying file contents — Docker reuses it and skips execution. The moment a layer doesn't match, that layer and every layer after it are treated as a cache miss and get rebuilt from scratch.
The build cache is checked top to bottom, so a single miss anywhere in the middle forces everything below it to rerun, whether or not those layers actually changed. That's why instruction order determines your build speed more than almost anything else.
The Most Common Trap: Copying Everything Before Installing Dependencies
When people write their first Dockerfile, they tend to follow their normal dev workflow: copy the whole project in, then run the install command.
# what not to do
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "index.js"]The problem is COPY . .. Change a single file in the project — even just a line in the README — and that layer's cache is invalidated. And because it sits right before RUN npm install, that layer gets invalidated too, forcing a full reinstall even though package.json never changed.
Most day-to-day changes touch application logic, not dependencies — dependencies are actually the more stable part of the stack. Put the volatile stuff first, and you've cancelled out the whole point of caching.
Fix #1: Order by Change Frequency, Stable Stuff on Top
Optimizing the build cache really comes down to one idea: put the instructions that change least often as early in the Dockerfile as possible, and push the ones that change most often (usually copying source code) to the end.
FROM node:20-alpine
WORKDIR /app
# Copy just the files that determine dependencies — these rarely change
COPY package.json package-lock.json ./
RUN npm install
# Source code changes often, so it goes last
COPY . .
CMD ["node", "index.js"]Now, as long as package.json and package-lock.json stay the same, RUN npm install keeps hitting the cache no matter how many times the source changes — no reinstall needed. The trick is splitting "the files that determine what gets installed" from "everything else" into two separate COPY instructions, so things with different change frequencies don't end up sharing a layer.
The same logic applies to other languages too.
Fix #2: Multi-Stage Builds to Separate Build Output from Runtime
The technique above solves "don't reinstall dependencies unnecessarily," but if your project has a compile or bundling step — TypeScript, React, that kind of thing — the final runtime environment doesn't actually need the devDependencies, the source code, or the build tools. It just needs the compiled output.
That's what multi-stage builds are for: split a single Dockerfile into multiple FROM stages, where the earlier stages handle the build and the last stage only pulls in what it actually needs.
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
# assume the compiled output lands in /app/dist
# Stage 2: runtime
FROM node:20-alpine AS runner
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]COPY --from=builder only pulls the specified files from the previous stage — the source code, devDependencies, and other build-time intermediates all stay behind in the builder stage and never make it into the final image. That keeps the final image smaller with less attack surface, and since the build stage and the runtime stage cache independently, neither one drags the other down.
Fix #3: Use .dockerignore So Unrelated Files Don't Wreck the Cache
For an instruction like COPY . ., Docker decides whether the layer changed by hashing the contents of the entire directory. If that directory has node_modules, .git, or editor temp files mixed in, any change to those — even ones you never touched — can invalidate the layer for no reason.
.dockerignore works almost exactly like .gitignore:
node_modules
.git
.next
dist
*.log
.env
Excluding these files doesn't just make the cache more stable — it also stops you from accidentally shipping your local node_modules (which might contain native modules built for a different OS) into the image, or baking a secrets-laden .env file in by mistake.
Going Further: BuildKit Cache Mounts Keep Even the Package Manager's Own Cache
Everything so far is about whether a layer needs to rerun at all. But if package.json actually changes, npm install still has to fetch every package from scratch. That's where BuildKit's --mount=type=cache comes in — it mounts the package manager's own download cache as a directory that persists across builds.
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm install
COPY . .
CMD ["node", "index.js"]/root/.npm is where npm keeps its download cache. Mount it with type=cache and BuildKit preserves that directory independently — it never gets baked into an image layer, but it's still there on the next build. Even if package.json changes and dependencies need to be re-resolved, npm can just pull the packages from its local cache instead of hitting the network again, and the speed difference is significant.
Wrapping Up
Whether a Dockerfile rebuilds everything on every run has nothing to do with project size — it comes down to whether your instruction order respects how the build cache actually works: top to bottom, one miss invalidates everything after it. Put low-frequency-change instructions (like installing dependencies) first, push high-frequency-change instructions (like copying source code) to the end, then layer on multi-stage builds to shrink the final image, .dockerignore to cut out noise, and BuildKit cache mounts where it counts. Do that, and most builds only need to rerun the small part that actually changed — not start over from scratch.