Caching the pnpm Store in GitHub Actions
Every CI run re-downloads all dependencies because the pnpm store is not cached, adding minutes to each job. This page shows how to cache the content-addressed store correctly so installs become near-instant without risking a stale or poisoned cache.
Exact symptoms and error messages
The install step dominates CI time and shows a full cold download every run:
Progress: resolved 1284, reused 0, downloaded 1284, added 1284
Done in 96s
reused 0 means nothing came from a local store — the cache is cold on every job.
Root cause analysis
pnpm keeps packages in a content-addressed store and links them into node_modules, so caching the store makes the next install a link step instead of a download. If the cache key is not tied to the lockfile, though, a cache hit can restore a store that no longer matches the resolved graph — the reproducibility hazard covered in Lockfile Management Strategies.
pnpm's architecture is what makes store caching so effective. Packages are stored once, content-addressed, in a global store, and node_modules is built from hard links and symlinks into it. A warm store therefore turns install into a linking operation rather than a network download, which is why reused counts jump once the cache is restored. This is the same store described in Workspace Symlinks vs Hard Links.
The correctness risk lives in the cache key. If the key does not change when the resolved dependency set changes, a cache hit can restore a store that no longer matches the lockfile, and a subsequent install may quietly satisfy some packages from the stale store. Keying on the lockfile hash ties the cache's validity to exactly the thing that determines the resolved graph, which is why the lockfile — not package.json — is the correct key input.
Resolution and configuration patch
Key the cache on the lockfile hash so it invalidates exactly when dependencies change:
- uses: pnpm/action-setup@v4
with: { version: 10 }
- name: Get pnpm store path
run: echo "STORE=$(pnpm store path)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
with:
path: ${{ env.STORE }}
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- run: pnpm install --frozen-lockfile --ignore-scripts
Add a restore-keys fallback so a near-miss (a small dependency change) still starts from a warm-ish store rather than cold, while --frozen-lockfile guarantees the restored store can never mutate the resolved graph:
- uses: actions/cache@v4
with:
path: ${{ env.STORE }}
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-
- run: pnpm install --frozen-lockfile --ignore-scripts
The fallback restores the most recent compatible store, and the frozen install fetches only the delta, so even a dependency bump pays a small incremental cost rather than a full cold download.
CLI validation and debug commands
# Confirm the store path CI is caching
pnpm store path
# After a warm run, expect high 'reused' counts
pnpm install --frozen-lockfile 2>&1 | grep reused
# Prune stale content to keep the cache small
pnpm store prune
Prevention and CI guardrails
- Key the cache on
hashFiles('pnpm-lock.yaml')so a dependency change busts it. - Keep
--frozen-lockfileso a cache hit can never mutate the resolved graph. - Run
pnpm store pruneperiodically so the cached store does not grow unbounded. - Cache the store, not
node_modules— the store links are cheaper and safer to restore.
Keeping the cached store from growing unbounded
A store cache that only ever grows eventually costs more to restore than it saves. Every dependency version you have ever installed accumulates in the store, so without pruning the cache balloons and the restore step slows down — silently eroding the very speed-up you added it for.
# Remove store content no lockfile references
pnpm store prune
Run pnpm store prune on a schedule (or before the cache is saved) so orphaned versions are dropped. The goal is a store that tracks your current dependency set, not your entire history. Pair pruning with a cache key that rotates on the lockfile, and the cached store stays proportional to what your project actually needs rather than everything it has ever touched.
Why caching the store beats caching node_modules
It is tempting to cache node_modules directly, but for pnpm it is the wrong layer. node_modules is a dense tree of symlinks into the store; restoring it means recreating thousands of links, which is slow and fragile across runners, and it does not deduplicate the way the store does. Caching the store instead keeps the cache compact and lets pnpm rebuild node_modules as a fast local linking step.
The practical payoff is both speed and correctness. A restored store plus a frozen install produces exactly the tree the lockfile describes, verified against integrity hashes, whereas a restored node_modules can carry stale or partially-linked state from a previous run. Cache the content-addressed store, let pnpm materialize the tree, and you get the reproducibility of a clean install at close to the speed of no install at all.
Frequently Asked Questions
Should I cache node_modules or the pnpm store?
Cache the store. pnpm links packages from the store into node_modules, so a warm store makes install a fast linking step, and it avoids restoring a huge, symlink-heavy node_modules tree.
Why include the lockfile in the cache key?
So the cache invalidates exactly when the resolved dependency set changes. A key without it can restore a store that no longer matches the lockfile, producing a subtly wrong install.
Why does reused 0 mean the cache isn't working?
reused counts packages linked from a local store rather than downloaded. Zero means the store was cold, so every package came over the network. A correctly restored store cache makes reused climb to nearly the full dependency count.
Do I need pnpm store prune if I key on the lockfile?
Keying rotates the cache but does not shrink the store it saves — old versions accumulate. Prune periodically so the cached store tracks your current dependency set rather than your entire install history.
Is a restore-keys fallback safe with a frozen install?
Yes. The fallback only provides a warm starting store; --frozen-lockfile still fetches the exact delta the lockfile requires and refuses to mutate the resolved graph, so correctness is unaffected.
Related
- CI/CD Pipeline Optimization for Monorepos — the overview for fast monorepo pipelines.