← Back to Docker Cache Order Check

Why Docker build cache gets busted

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.

Mistake 1: copying the whole build context before installing dependencies

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.

Mistake 2: an install with no isolated manifest copy at all

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

Mistake 3: apt-get update in its own layer

Docker'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/*

What this tool checks, and what it deliberately doesn't

CheckSourceOut of scope for v0
Manifest copy/install after a broad copyText/regex scan of COPY/ADD and RUN instruction order, per build stageWhether 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 copySame scan, checking whether any earlier COPY/ADD names the ecosystem's manifest file specificallyManifests split across multiple non-standard file names this tool's ecosystem list does not recognize
apt update/install splitText match for apt-get update/apt update and install/upgrade/dist-upgrade within the same RUNOther package managers' equivalent split (apk update/apk add, yum makecache/yum install) — not checked in v0

Wiring it into CI before a merge

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.

Common questions

Isn't this just what hadolint or a Dockerfile linter already does?

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

Does BuildKit already fix this automatically with its own caching improvements?

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.

Will this tell me whether my build cache is actually being reused?

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.