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.

Apr 8, 2025·Updated ·5 min readBeginner·By SecOpsLog · documentation-verified

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.

Dockerfile (the slow version)
FROM node:24-slim
WORKDIR /app
COPY . . # 1. the whole tree, so this layer's hash changes on every commit
RUN npm ci # 2. therefore this layer is rebuilt on every commit
RUN npm run build
CMD ["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

Dockerfile (the fast version)
# syntax=docker/dockerfile:1
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./ # changes only when dependencies change
RUN --mount=type=cache,target=/root/.npm \
npm ci # cached until the lockfile changes
COPY . . # the volatile layer, now below the install
RUN npm run build
CMD ["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).

bash — the same code-only commit, after the reorder
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.1s
only the layers below the source copy ran

The 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.

.dockerignore
.git
node_modules
dist
coverage
*.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.

A cache hit is a decision not to look
A CACHED install layer means Docker skipped npm ci entirely, so a vulnerability fixed in a patch release yesterday is not in the image until the lockfile changes. Scan the built image in CI regardless of cache state, and rebuild with --no-cache (or on a schedule) so base-image and dependency fixes are picked up even when nobody touched the lockfile.

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.

Related posts

Quick reference