← Back to Docker Cache Order Check
Docker builds an image layer by layer, one per instruction, and caches each layer keyed on that instruction and everything that fed it. On a later build, the moment one layer's cache key changes, that layer and every layer after it re-run from scratch — caching is not per-instruction independent, it is a chain that breaks at the first change and stays broken for the rest of the build. All three checks here are different ways of breaking that chain earlier than necessary.
A very common Dockerfile shape:
FROM node:20 WORKDIR /app COPY . . RUN npm ci
COPY . .'s cache key is derived from every file in your
build context. Change one line of application source — not a dependency —
and that COPY layer's key changes, which invalidates it and
the RUN npm ci layer after it, so dependencies reinstall on
every single build regardless of whether package.json
actually changed. Docker's own "Leverage build cache" documentation gives
the fix directly: copy the manifest alone first, install, then
copy the rest of the source:
FROM node:20 WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . .
Now RUN npm ci's cache key depends only on the two files
named in the COPY immediately before it. A source-only change
invalidates the final COPY . . but reuses the cached install
layer untouched.
Sometimes there is no separate manifest COPY to reorder —
the manifest only ever reaches the image via a broad copy:
FROM python:3.12 COPY . . RUN pip install -r requirements.txt
Reordering these two lines does not fix anything, because there is no
instruction whose cache key depends on requirements.txt alone
— COPY . .'s key depends on the entire tree either way. The
fix is to add a manifest-only copy, not just move existing lines:
FROM python:3.12 COPY requirements.txt . RUN pip install -r requirements.txt COPY . .
apt-get update in its own layerDocker's own Dockerfile best practices documentation names this one directly. Splitting the package index refresh from the install:
RUN apt-get update RUN apt-get install -y curl
caches the update as its own layer. On a later build where
nothing above it changed, Docker reuses that cached layer rather than
re-running apt-get update — so the install step can resolve
package versions against an index that is now stale, weeks or months old,
even though the RUN apt-get install line itself looks
unchanged. Combining them in one instruction means the index is always as
fresh as the layer that installs against it:
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
| Check | Source | Out of scope for v0 |
|---|---|---|
| Manifest copy/install after a broad copy | Text/regex scan of COPY/ADD and RUN instruction order, per build stage | Whether the broad copy was actually necessary before the manifest copy for some other reason specific to your build — this tool reports the pattern, not your intent |
| Install with no isolated manifest copy | Same scan, checking whether any earlier COPY/ADD names the ecosystem's manifest file specifically | Manifests split across multiple non-standard file names this tool's ecosystem list does not recognize |
| apt update/install split | Text match for apt-get update/apt update and install/upgrade/dist-upgrade within the same RUN | Other package managers' equivalent split (apk update/apk add, yum makecache/yum install) — not checked in v0 |
Check a changed Dockerfile before it merges:
- name: Check Dockerfile cache order run: node ventures/docker-cache-order-check/cli.mjs --fail-on-risk
Exits 2 when any of the three patterns is found, so a Dockerfile change that would reinstall dependencies on every build is caught in the same review that introduces it.
A per-line Dockerfile linter (hadolint and similar tools) checks each instruction in isolation against a rule — pin this FROM tag, add --no-install-recommends here. As far as this project could determine from hadolint's own published rule index, none of its rules reason about the relative order of two different instructions across a Dockerfile, which is what all three checks here do: they only make sense read as a sequence (was the manifest copied before or after the broad copy; did the same RUN combine update and install).
No. BuildKit changed how layers are cached and introduced cache mounts (RUN --mount=type=cache) for a package manager's own download cache, which is a genuinely different, complementary optimization. It does not reorder your instructions or infer that a COPY . . before an install was a mistake — the Dockerfile's instruction order is still exactly what you wrote, and BuildKit still invalidates a layer whenever an earlier layer's cache key changes, which is the mechanism this checker's three patterns each defeat.
No, and it does not try to. Measuring actual cache hit rate requires running real builds and reading BuildKit's own build output. This tool is a static check of the Dockerfile text: it reports an instruction order that is a documented cause of cache invalidation, before you have run a single build, so you can fix it in review instead of noticing it from a slow CI pipeline.