Caching dependencies keyed on the lockfile Jump to heading

Dependency installation is usually the largest single phase of a pipeline and the easiest to remove, because its inputs are one file. Yet cache configuration goes wrong in a consistent way: the key includes something that varies per run, so the cache never hits, or the fallback is so broad that a hit restores a tree built from a different lockfile and the failure appears somewhere unrelated. This recipe gets both right, within CI caching and runner performance.

When to use this approach Jump to heading

  • Dependency installation takes more than about twenty seconds per job.
  • Several jobs in the same pipeline install the same dependencies.
  • Your cache hit rate is unknown, which usually means it is low.
  • A build has failed in a way that a clean checkout fixed.
  • If your project has three dependencies, the cache overhead may exceed the saving; measure before configuring.

Step 1 β€” Cache the store, not the installed tree Jump to heading

Package managers have a content-addressed download store and a resolved tree. The store is stable, shared across projects and safe to restore; the tree contains platform-specific artefacts and symlinks that do not always survive archiving.

      - uses: actions/cache@v4
        with:
          path: ~/.npm                 # the store β€” not node_modules
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
      - run: npm ci --prefer-offline   # resolve from the store, network only on a miss
# The equivalents in other ecosystems
# pip:    ~/.cache/pip           key on requirements*.txt / poetry.lock
# go:     ~/go/pkg/mod           key on go.sum
# cargo:  ~/.cargo/registry      key on Cargo.lock
# maven:  ~/.m2/repository       key on pom.xml
# Verification: an offline install must succeed after a warm cache
npm ci --prefer-offline --no-audit --no-fund 2>&1 | tail -3
Caching the store against caching the installed treeThe download store is content-addressed, portable between projects and safe to archive. The installed tree contains compiled binaries and symlinks that depend on the runner image, so restoring it across image versions produces failures that look unrelated to caching.Cache node_modulesCache the storeportable across imagesno β€” native buildsyessymlinks surviveunreliablenot applicableinstall still validatesskipped entirelyyes, from the lockfilerestore sizelargesmaller, sharedcaching the tree skips the step that would have caught a lockfile mismatch

Step 2 β€” Hash exactly the files that determine the tree Jump to heading

Too little in the key and the cache is wrong; too much and it never hits.

          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
# Verification: what does that glob actually match in your repository?
git ls-files | grep -E 'package-lock.json$'
# And confirm the hash is stable across a clean checkout
git stash list >/dev/null; sha256sum $(git ls-files | grep 'package-lock.json$') | sha256sum

Two things commonly and wrongly end up in the key: the branch name, which guarantees a miss on every new branch, and a timestamp, which guarantees a miss always. If either is present, the cache has never worked and its absence has never been noticed.

Step 3 β€” Make restore keys narrow, not convenient Jump to heading

A restore key is a prefix match used when the exact key misses. It is useful because a partial store still saves most of the download β€” but only when the prefix cannot cross into a different trust or platform context.

          restore-keys: |
            npm-${{ runner.os }}-
# Verification: which entries would that prefix match?
gh cache list --limit 30 --json key,createdAt,sizeInBytes \
  --jq '.[] | select(.key | startswith("npm-Linux-")) | {key, sizeInBytes}'

SAFETY WARNING β€” a restore key that matches entries written by less trusted contexts turns the cache into an injection path. If pull requests from forks can write a cache entry the default branch later restores, a contributor can place arbitrary content into a trusted build. Keep write access scoped to the default branch and never add a prefix so broad that it spans that boundary.

Step 4 β€” Measure the hit rate before tuning further Jump to heading

Cache configuration is frequently adjusted by intuition. The hit rate is observable and settles the argument.

# Hits and misses across recent runs, from the step logs
for id in $(gh run list --workflow ci.yml --limit 20 --json databaseId --jq '.[].databaseId'); do
  gh run view "$id" --log 2>/dev/null | grep -qi 'cache restored from key' && echo hit || echo miss
done | sort | uniq -c
Hit rate before and after fixing the keyWith the branch name in the key, every new branch misses and the rate sits near a third. Keying on the lockfile hash alone raises it to nearly every run, because most pull requests do not change dependencies at all.cache hit rate across twenty runsbranch name in the key35%lockfile hash only91%most changes touch no dependency, so a correct key hits almost every time

Step 5 β€” Handle the eviction cliff Jump to heading

Providers evict by total size and by age. A repository generating a new cache entry per branch will churn through the quota and evict the entries it needs.

# How much of the quota is being used, and by what?
gh cache list --limit 100 --json key,sizeInBytes \
  --jq 'group_by(.key | split("-")[0])[] | {family: .[0].key | split("-")[0],
        entries: length, mb: ((map(.sizeInBytes) | add) / 1048576 | floor)}'
# Delete stale families deliberately rather than waiting for eviction
gh cache delete --all --key 'npm-Linux-old-prefix' 2>/dev/null || true
Why a cache is missing when the key looks correctA miss with a correct key is either eviction, a different runner platform, or a scope boundary β€” a cache written on one branch is not visible to another unless it is the default branch. Each has a different fix, and guessing between them wastes an afternoon.The key is right but nothing restores. Why?entry no longer listedEvictedreduce the number of familiesdifferent runner imagePlatform mismatchinclude the OS in the keywritten on another branchScope boundarywarm it on the default branchgh cache list answers all three in one command

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Should each job have its own cache? Jump to heading

They should share one, keyed identically, so the first job to run warms it for the rest. Separate caches per job multiply the storage and guarantee that at least one job pays the cold cost on every run.

Why does the first run on a new branch still miss? Jump to heading

Because the exact key has never been written from a context this branch can read. A restore key gives it a partial hit, and warming the cache from the default branch on a schedule makes that partial hit reliable.

Is --prefer-offline safe? Jump to heading

Yes β€” it prefers the local store and falls back to the network for anything missing, so the resolved tree is identical. What it removes is the round trip for packages already present, which is the entire point of the cache.