Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

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:

Exact symptoms and error messages The install step dominates CI time and shows a full cold download every run: Exact symptoms and error messages The install step dominates CI time and shows a full cold download every run:
Exact symptoms and error messages — the core idea of this section at a glance.
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.

Root cause analysis pnpm keeps packages in a content-addressed store and links them into nodemodules, so caching the store makes the next in Root cause analysis pnpm keeps packages in a content-addressed store and links them into nodemodules, so caching the store makes the next install a link step instead of a download.
Root cause analysis — the core idea of this section at a glance.

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.

Every CI run re-downloads all dependencies when the pnpm store is not cached, because a fresh runner starts with an empty store. pnpm keeps packages in a content-addressed store and links them into node_modules, so caching the store makes the next install a linking step instead of a download — which is why a warm store dramatically cuts install time. The reused 0 line in the install output is the tell that the store was cold and everything came over the network.

The correctness risk lives in the cache key. If the key is not tied to the lockfile, a cache hit can restore a store that no longer matches the resolved graph, 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 dependency set, so the cache invalidates precisely when dependencies change.

Resolution and configuration patch

Key the cache on the lockfile hash so it invalidates exactly when dependencies change:

Resolution and configuration patch Key the cache on the lockfile hash so it invalidates exactly when dependencies change: Resolution and configuration patch Key the cache on the lockfile hash so it invalidates exactly when dependencies change:
Resolution and configuration patch — the core idea of this section at a glance.
- 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.

Key the cache on the lockfile hash so it invalidates 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

The restore-keys fallback restores a near-miss store so a small dependency change starts warm, and --frozen-lockfile guarantees the restored store cannot mutate the resolved graph.

CLI validation and debug commands

CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows. CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows.
CLI validation and debug commands — the core idea of this section at a glance.
# 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-lockfile so a cache hit can never mutate the resolved graph.
  • Run pnpm store prune periodically so the cached store does not grow unbounded.
  • Cache the store, not node_modules — the store links are cheaper and safer to restore.
Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows. Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows.
Prevention and CI guardrails — the core idea of this section at a glance.
  • Key the cache on hashFiles('pnpm-lock.yaml') so a dependency change busts it.
  • Keep --frozen-lockfile so a cache hit can never mutate the resolved graph.
  • Run pnpm store prune periodically 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.

Store pruning Prune orphaned versions so the cache tracks current needs. store grows every version kept pnpm store prune drop orphans compact cache fast restore
Pruning keeps the cached store proportional to your current dependency set.
# 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.

Store vs node_modules Caching node_modules versus caching the content-addressed store. Cache node_modules • thousands of symlinks • slow, fragile restore • stale-state risk Cache the store • content-addressed • fast local relink • clean-install correctness
Cache the store and let pnpm rebuild the tree — compact, correct, and fast.

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.

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 from the restored store.

Why caching the store beats caching nodemodules It is tempting to cache nodemodules directly, but for pnpm it is the wrong layer. Why caching the store beats caching nodemodules It is tempting to cache nodemodules directly, but for pnpm it is the wrong layer.
Why caching the store beats caching nodemodules — the core idea of this section at a glance.

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. This is the pnpm-specific version of a general CI principle: cache the durable, content-addressed layer and reconstruct the derived layer, rather than caching the derived layer and hoping it restores cleanly across different runner environments.

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. Running pnpm store prune on a schedule, or before the cache is saved, drops the content that no current lockfile references, so the store tracks your current dependency set rather than your entire history.

Keeping the cached store from growing unbounded A store cache that only ever grows eventually costs more to restore than it saves. Keeping the cached store from growing unbounded A store cache that only ever grows eventually costs more to restore than it saves.
Keeping the cached store from growing unbounded — the core idea of this section at a glance.
# Remove store content no lockfile references
pnpm store prune

Pair pruning with a cache key that rotates on the lockfile, and the cached store stays proportional to what your projects actually need. The interaction matters: the lockfile-keyed cache rotates the saved snapshot when dependencies change, but without pruning each snapshot carries the accumulated cruft of every prior version. Pruning before the save keeps each snapshot lean, so both the upload and the restore stay fast. A well-maintained store cache is one of the highest-value CI optimizations for a pnpm monorepo, but only if it is kept proportional — an unbounded store cache eventually becomes the slow step it was meant to eliminate.

Restore-keys and warm-starting a changed lockfile

A cache key tied to the exact lockfile hash misses whenever any dependency changes, which for an active repository can be often — and a full miss means a cold store and a slow install. The restore-keys fallback softens this: when the exact key misses, GitHub restores the most recent cache whose key shares the prefix, so a small dependency change starts from a near-complete store and downloads only the delta rather than everything.

Warm-start fallback A prefix restore-key warms a changed-lockfile install. exact key misses dependency changed restore-keys prefix near-complete store frozen install fetch only delta
restore-keys start a changed-lockfile install warm, paying only the delta.
- uses: actions/cache@v4
  with:
    path: ${{ env.STORE }}
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

The exact key still saves a fresh cache after the install, so the next run with the same lockfile hits exactly; the fallback only provides a warm starting point when the lockfile changed. Combined with --frozen-lockfile, this is safe: the fallback store is just a head start, and the frozen install fetches exactly the delta the current lockfile requires and refuses to mutate the resolved graph. The result is that even a dependency bump pays a small incremental install cost rather than a full cold download, which keeps CI fast across the frequent small lockfile changes an active repository produces.

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.

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 that is slow and fragile across runners.

Why include the lockfile hash 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. hashFiles('pnpm-lock.yaml') ties validity to the resolved graph.

Do I need pnpm store prune if I key on the lockfile?

Yes — keying rotates the saved snapshot but does not shrink the store it saves, so old versions accumulate in each snapshot. Prune before the save so the cached store tracks your current dependency set and the restore stays fast.

What do restore-keys do for a pnpm store cache?

They provide a fallback: when the exact lockfile-hash key misses (a dependency changed), GitHub restores the most recent cache sharing the key prefix, so the install starts from a near-complete store and downloads only the delta. With --frozen-lockfile this is safe — the fallback is just a warm head start.

Related

CI/CD Pipeline Optimization for Monorepos