Skip to content
compiler.dev

Cache pnpm Dependencies in GitHub Actions

Last updated

To cache pnpm in GitHub Actions, install pnpm first, then set cache: pnpm on actions/setup-node. That caches pnpm's content-addressable store, keyed on pnpm-lock.yaml. The order matters: setup-node needs pnpm to exist before it runs, so pnpm/action-setup goes above it.

Option 1: setup-node cache

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm test

pnpm/action-setup can omit version when package.json has a packageManager field (for example "packageManager": "pnpm@10.9.0"). That is the cleanest setup: one place defines the pnpm version for your laptop and CI. Otherwise pass with: { version: 10 }.

The setup-node README (checked October 2026) says the pnpm cache value requires pnpm 6.10 or newer and that pnpm must already be installed. If you see Unable to locate executable file: pnpm, the step order is wrong.

Option 2: let pnpm/action-setup cache the store

pnpm/action-setup has its own cache input, off by default. Its README describes it as caching the pnpm store directory, with the key from pnpm-lock.yaml (change that with cache_dependency_path).

- uses: pnpm/action-setup@v6
  with:
    cache: true
- uses: actions/setup-node@v7
  with:
    node-version: 22
- run: pnpm install --frozen-lockfile

Pick one mechanism, not both. Two caches of the same store waste quota.

Option 3: explicit actions/cache

Ask pnpm where the store is rather than guessing. The location differs by OS and pnpm version.

- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
  with:
    node-version: 22
- name: Get pnpm store path
  id: pnpm-store
  run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v6
  with:
    path: ${{ steps.pnpm-store.outputs.path }}
    key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-
- run: pnpm install --frozen-lockfile

The hashFiles('**/pnpm-lock.yaml') glob handles repos with more than one lockfile. In a standard pnpm workspace there is one lockfile at the root, so the hash is stable and changes only when dependencies do.

What to cache and what not to

pnpm keeps every package once in the store and hard-links (or copies) files into each project's node_modules. That design is why caching the store is enough.

  • Cache the store. It is the expensive part: network downloads.
  • Do not cache node_modules. It is a tree of links into the store. Restored from a tarball it becomes full copies, can be huge, and may not match the store layout on the next run.
  • Do not hard-code the store path. Use pnpm store path.

One caveat: hard links only work within one filesystem. If the store restores onto a different volume than your workspace, pnpm falls back to copying, which is slower but still correct.

Workspaces

For a workspace, install once at the root and run tasks with filters:

- run: pnpm install --frozen-lockfile
- run: pnpm --filter "...[origin/main]" test

For the filter to work, the checkout needs enough history. See fastest CI for pnpm workspaces for the full setup including fetch-depth.

How to verify a cache hit

  • The setup-node post step logs Cache saved with key: ... on a miss. On a hit the restore step logs Cache restored from key: ....
  • Time pnpm install. With a warm store, pnpm's progress line reports packages as reused rather than downloaded.
  • gh cache list --key pnpm- --limit 20 shows entries, sizes and last-accessed times.

A complete example with a matrix

Caches are keyed per OS, so a matrix over Node versions produces one entry per cell unless you share the store. The pnpm store is mostly independent of the Node version, so a key without the Node version is usually fine and saves quota:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: [20, 22]
    steps:
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node }}
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm test

setup-node builds its key from the OS, the package manager and the lockfile hash, so both matrix cells restore the same store entry. Packages with native add-ons are rebuilt per Node version during install, but their downloads still come from the cache. Check the cache savings estimator to see what the hit rate is worth for your run count.

Common mistakes

  1. Setup order. actions/setup-node before pnpm/action-setup fails for cache: pnpm.
  2. Mismatched pnpm versions. The store layout changes between major versions. Include the pnpm major in your key if you upgrade often.
  3. Using pnpm install without --frozen-lockfile. Be explicit in CI so a stale lockfile fails the build instead of silently resolving new versions.
  4. Writing the cache only on pull requests. PR caches are scoped to the PR merge ref. Populate it from main as well. Details in Caching in GitHub Actions.
  5. Forgetting the 10 GB cap. Each OS and Node combination makes a new entry. See cache limits.

FAQ

Where is the pnpm store on a GitHub-hosted runner?

Run pnpm store path in a step to print it. It depends on the OS and pnpm version, which is why the explicit recipe reads it dynamically.

Is pnpm faster than npm in CI?

Often on a warm store, because it links files rather than copying them. The gain depends on your tree; measure both with the same cache state.

Should I commit the pnpm store?

No. A cache is the right place for it.

What about Turborepo or Nx caches?

Those cache task outputs, not dependencies. See monorepo CI with Nx or Turborepo.

Next steps

Compare with the npm and Yarn setups. If installs are fast and the job is still slow, the bottleneck is CPU; compiler.dev runs the same workflow on 8 vCPU machines for $0.012 per minute (see pricing).

Run this workflow on compiler.dev 200 free minutes

Start free