Dockerfile layer caching: order matters more than you think
Order your instructions so dependency layers stay cached and only your code layer rebuilds — seconds, not minutes.
The symptom is a CI log in which npm ci (or pip install, or go mod download) runs in full on every push, forty seconds of downloading a tree that did not change, followed by a build step that only needed ten. The cause is one line in the Dockerfile, and it is almost always the same line.
FROM node:24-slimWORKDIR /appCOPY . . # 1. the whole tree, so this layer's hash changes on every commitRUN npm ci # 2. therefore this layer is rebuilt on every commitRUN npm run buildCMD ["node", "dist/server.js"]
How the cache decides
Docker keys each layer on the instruction text and, for COPY and ADD, on the checksum of the files copied. When a layer's key changes, that layer and every layer after it are rebuilt; there is no partial reuse below a miss. COPY . . at the top means any change anywhere in the context (a README edit, a test fixture, a new file in .git) produces a new checksum, and the dependency install that follows it is rebuilt from nothing. The fix is to order instructions from least to most volatile, so that the expensive layer sits above the one that changes on every commit.
Lockfile first, source last, downloads in a cache mount
# syntax=docker/dockerfile:1FROM node:24-slimWORKDIR /appCOPY package.json package-lock.json ./ # changes only when dependencies changeRUN --mount=type=cache,target=/root/.npm \npm ci # cached until the lockfile changesCOPY . . # the volatile layer, now below the installRUN npm run buildCMD ["node", "dist/server.js"]
Two things changed. The manifest files are copied alone, so the npm ci layer's key depends only on them and survives every code-only commit. And RUN --mount=type=cache gives npm a download directory that persists on the builder across builds, outside any layer: when the lockfile does change and the install layer has to rerun, packages already downloaded are served from that directory instead of from the registry. The same shape works for Python (requirements.txt or pyproject.toml plus lock, cache mount on /root/.cache/pip), Go (go.mod and go.sum, then go mod download with a mount on /go/pkg/mod) and apt (/var/cache/apt).
docker build -t app . => CACHED [3/6] COPY package.json package-lock.json ./ => CACHED [4/6] RUN --mount=type=cache,target=/root/.npm npm ci => [5/6] COPY . . => [6/6] RUN npm run build => exporting to image 3.1sonly the layers below the source copy ranThe context is part of the cache key
COPY . . hashes whatever the build context contains, so a .dockerignore that excludes .git, a local node_modules, coverage output and test fixtures does two jobs: it stops those files from changing the hash, and it keeps them out of the image. A context that includes .git also means every commit changes the checksum even when no source file did, which is a cache miss you would never find by reading the Dockerfile.
.gitnode_modulesdistcoverage*.log.env*
Everything above assumes the builder keeps its cache between runs, which a laptop does and an ephemeral CI runner does not. On runners that start empty, BuildKit can read and write the layer cache through a registry: docker buildx build --cache-from type=registry,ref=registry.acme.dev/app:cache --cache-to type=registry,ref=registry.acme.dev/app:cache,mode=max . exports the layers (with mode=max, intermediate stages included) after the build and imports them before the next one, so the lockfile-first ordering pays off on a fresh machine too. The cache mount for npm's download directory is per builder and does not travel this way; on ephemeral runners the registry cache is what carries the install layer.
Layer order is the free win; the next one is splitting build and runtime stages so the cached toolchain never ships. The same keying rule, hash the lockfile rather than the commit, applies to dependency caches in GitHub Actions and GitLab, where the cache lives outside the image altogether.