CI is green, but a teammate's fresh clone fails with Cannot find module 'zod/v4' — CI restored a cached node_modules from before the dependency was upgraded, so the build never installed the new version the lockfile specifies. On another project, CI spends four minutes on pip install every run because the cache key includes the date and never hits. Dependency caching is supposed to make CI fast without changing its results; done wrong, it makes CI fast and different from a clean install. This page caches the right directories with the right keys in CI and in Docker builds, as part of CI/CD pipeline parity checks.

The rule that keeps caches honest: cache the package manager's download store, keyed by the lockfile, and always run the lockfile install — never cache the installed tree and skip installation.

Diagnostic

Inspect what the workflow caches, how it is keyed, and whether install steps run after a restore:

#!/usr/bin/env bash
set -euo pipefail
grep -nE -A6 'actions/cache@' .github/workflows/*.yml | grep -E 'path:|key:|restore-keys:' 
grep -nE "if: steps\..*cache-hit != 'true'" .github/workflows/*.yml || echo "install always runs after restore"
gh run view --log "$(gh run list --limit 1 --json databaseId -q '.[0].databaseId')" 2>/dev/null | grep -E 'Cache restored|Cache not found' | head -3

Expected bad output:

.github/workflows/test.yml:21:          path: node_modules
.github/workflows/test.yml:22:          key: deps-${{ runner.os }}-${{ hashFiles('package.json') }}
.github/workflows/test.yml:23:          restore-keys: deps-${{ runner.os }}-
.github/workflows/test.yml:26:        if: steps.deps.outputs.cache-hit != 'true'
Cache restored from key: deps-Linux-

The cache stores node_modules itself, keys it on package.json rather than the lockfile, falls back to any older cache through restore-keys, and skips npm ci on a hit — so a stale tree from a partial key match becomes the tree CI tests against.

Caching node_modules vs Caching the Store Comparison of caching the installed dependency tree against caching the package manager download store. Caching node_modules vs Caching the Store cache node_modules, skip install cache ~/.npm, always npm ci stale tree on partial hit lockfile always honoured differs from clean install same as clean install OS and arch specific binaries portable download cache fast but wrong fast and correct
Caching the store speeds up installs without letting a stale tree replace a real install.

Root cause

A dependency cache has two parts: what is stored and how it is found. Storing the installed tree (node_modules, a virtualenv, vendor/) and skipping the install on a hit means the tree is whatever was cached, not what the current lockfile describes. Keys based on package.json miss lockfile-only changes, and restore-keys prefixes deliberately restore older caches on a miss — which is fine for a download store (the installer fetches what is missing) and harmful for an installed tree (nothing corrects it). Keys that include volatile values such as dates or run numbers never hit, so the cache costs upload time for no benefit. Locally the same mistakes appear in Dockerfiles that copy a host node_modules or reuse a volume across lockfile changes. In every case the fix is the same: cache what the installer downloads, key it on the lockfile, and always run the installer.

The failure is particularly confusing because it inverts the usual expectation. People expect CI to be the strict, clean environment and laptops to be the messy ones. With a cached installed tree, CI becomes the environment carrying stale state, while a new hire's fresh clone is the clean one — so the new hire's failure looks like a local problem and gets debugged as such. Making CI install from the lockfile on every run restores the expectation that CI reflects exactly what the repository declares.

Cache poisoning is a related, rarer risk: a cache written by one branch and restored by another can carry dependencies that were never reviewed on the restoring branch. Keying on the lockfile hash and scoping caches to the default branch for writes limits that exposure, which is why most setup actions follow those defaults.

Resolution

  1. Cache the download store, keyed on the lockfile, and always install from the lockfile. For npm the setup action does this correctly when asked:
jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: .tool-versions
          cache: npm
          cache-dependency-path: package-lock.json
      - run: npm ci
      - run: npm test

cache: npm stores ~/.npm keyed on the lockfile hash; npm ci then installs exactly the lockfile, reading packages from the warm cache.

  1. Do the same for Python and Go with explicit keys:
      - uses: actions/setup-python@v5
        with:
          python-version-file: .tool-versions
          cache: pip
          cache-dependency-path: requirements.lock
      - run: pip install --require-hashes -r requirements.lock
      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod
          cache-dependency-path: go.sum
      - run: go build ./...
  1. Mirror the pattern in Docker builds with BuildKit cache mounts, which persist the download store without baking it into layers:
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
CMD ["node", "server.js"]
  1. Remove if: cache-hit conditions on install steps and any restore-keys on caches of installed trees:
#!/usr/bin/env bash
set -euo pipefail
grep -rnE "cache-hit != 'true'" .github/workflows && echo "remove these conditions: installs must always run" || echo "installs always run"
grep -rnE 'path:\s*(node_modules|\.venv|vendor)\s*$' .github/workflows && echo "cache the package store instead of the installed tree" || true
A Correct Cache Cycle Flow from lockfile hash to restoring the download store and always running the lockfile install. A Correct Cache Cycle lockfile hash cache key restore store ~/.npm, pip npm ci always exact tree save store on new key
The install always runs; the cache only makes its downloads fast.

Expected output

Run actions/setup-node@v4
Cache restored from key: node-cache-Linux-x64-npm-5f1c9a7e...
Run npm ci
added 1412 packages in 9s

The store cache hits on the exact lockfile hash, npm ci still runs and installs the precise tree in seconds, and the zod/v4 failure cannot recur because every run installs what the lockfile says.

A useful confirmation is to compare the installed tree against a clean install occasionally: run npm ci in a job with caching disabled and diff npm ls --all --json with a cached run. They should be identical. If they are not, something is still restoring an installed tree or a cache key is missing an input, and the diff shows exactly which package differs.

Prevention

  1. Lint workflows for cached installed trees and conditional installs, using the grep above as a required check.

  2. Run a weekly uncached build — the clean-install job from auditing transitive dependencies — so any difference between cached and clean behaviour is caught.

  3. Keep keys deterministic. Only lockfile hashes, OS and architecture belong in keys; dates, run numbers and branch names prevent hits without improving correctness.

Install Step Duration by Cache Strategy Bar chart comparing install duration with no cache, a volatile key, and a lockfile-keyed store cache. Install Step Duration by Cache Strategy no cache 118 s date in key, never hits 121 s store keyed on lockfile 11 s
A correct store cache is almost as fast as caching node_modules, and always installs the right tree.

Platform caveats

Multiple OS or architecture runners: include runner.os and runner.arch in keys; the setup actions do this automatically. Native modules are compiled per platform during install, which is another reason not to cache the installed tree.

Monorepos: point cache-dependency-path at every lockfile that affects the install (for example **/package-lock.json), or the key misses changes in sub-packages.

Local Docker builds: cache mounts live in the builder, not in layers; docker builder prune clears them, after which the next build downloads again but still produces the same tree.

Rollback

Restore the previous workflow; caching strategy changes do not affect application code:

#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- .github/workflows/test.yml

Frequently Asked Questions

Why not cache node_modules directly? It is faster.

It is marginally faster and can be wrong: a partial key match restores an old tree and skipping the install means CI tests code against dependencies the lockfile no longer specifies. Caching ~/.npm with npm ci is nearly as fast and always correct.

What should the cache key contain?

The lockfile hash, OS and architecture. Nothing volatile such as dates or run numbers, which cause perpetual misses.

Are restore-keys safe?

For download stores, yes: a partial restore gives the installer most packages and it fetches the rest. For installed trees, no, because nothing corrects a stale tree.

How does this relate to Docker layer caching?

Layer caching reuses whole build steps when inputs are unchanged; cache mounts reuse the download store inside a step that does run. Use both: copy the lockfile first so the install layer is cached when it has not changed, and mount the store for when it has.