Caching Dependencies Identically in CI and Locally
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.
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
- 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.
- 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 ./...
- 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 npm ci
COPY . .
CMD ["node", "server.js"]
- Remove
if: cache-hitconditions on install steps and anyrestore-keyson 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
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
Lint workflows for cached installed trees and conditional installs, using the grep above as a required check.
Run a weekly uncached build — the clean-install job from auditing transitive dependencies — so any difference between cached and clean behaviour is caught.
Keep keys deterministic. Only lockfile hashes, OS and architecture belong in keys; dates, run numbers and branch names prevent hits without improving correctness.
Platform caveats
Multiple OS or architecture runners: include
runner.osandrunner.archin 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-pathat 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 pruneclears 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.