pnpm Cache on GitHub Actions: Store and Workspaces
Cache the pnpm store with WarpBuilds/setup-node keyed on pnpm-lock.yaml, then map pnpm --filter onto a matrix of warp- runner sizes for each workspace package.
Last verified:
To cache pnpm on GitHub Actions, save the content-addressable store that pnpm store path reports and key it on the hash of pnpm-lock.yaml, so that pnpm install --frozen-lockfile links packages out of local disk instead of fetching them from the registry. On WarpBuild runners, WarpBuilds/setup-node with cache: pnpm performs that save and restore through WarpBuild Cache without a separate cache step.
A workspace adds a second decision on top of the cache: which packages a given job installs and which runner size it deserves. This page covers the store configuration for WarpBuilds/setup-node@v6, how pnpm --filter maps onto a matrix of warp- labels, the three bottlenecks that dominate pnpm pipelines, and the per-minute arithmetic against GitHub-hosted list prices.
Overview
Every GitHub Actions job starts on a fresh virtual machine with an empty disk. For a pnpm workspace that means the store is gone, node_modules is gone, and the install step resolves and downloads the entire dependency graph before the first test file loads.
pnpm keeps three layers on disk, and only one of them is worth caching.
The store is a global content-addressable directory holding each package version once, addressed by the hash of its contents. pnpm store path prints its location, which varies by platform and by whether a store-dir setting is in effect. This is the layer to cache.
The virtual store at node_modules/.pnpm holds one directory per package version, with the files hardlinked out of the global store. Hardlinks are close to free, which is why a warm store makes an install fast even though every package still lands in the virtual store.
Each package's own node_modules then holds symlinks into the virtual store, which is what gives pnpm its strict resolution: a package can only import what its own manifest declares.
Caching node_modules instead of the store fails for a structural reason. A workspace with 30 packages produces 30 symlink farms plus the virtual store, all pointing at absolute paths that only exist on the machine that created them. Restoring those links onto a different runner, or into a different workspace root, leaves dangling entries that surface as module resolution errors halfway through a test run. The store carries no path assumptions, so it restores cleanly and lets the install rebuild the links.
One detail decides whether a warm store is fast or merely warm. Hardlinks work within a single filesystem, so when the store and the workspace sit on different volumes pnpm falls back to copying every file. WarpBuild Linux runners expose a single 150GB SSD volume, so the default store location and the checkout land on the same filesystem and the link path holds.
Moving a job onto one is a change to the runs-on line. WarpBuild Cache is available on Linux runners and is not supported on Windows runners, so keep the store cache steps on Linux jobs and let any Windows leg of a matrix install cold.
Three boundaries before the configuration. A single-package repository with one lockfile is a simpler problem, covered on Node.js dependency caching on GitHub Actions. Caching the outputs of workspace tasks rather than the packages they consume is a different mechanism again, covered on Turborepo remote caching on GitHub Actions. A workspace that installs with Bun has its own store layout and its own lockfile name, covered on Bun installs and tests on GitHub Actions.
Configuration
The short form uses the WarpBuild fork of the Node setup action. From v6 that action turns npm caching on automatically when package.json declares npm as the package manager; pnpm always needs cache: pnpm set explicitly.
name: pnpm-ci
on:
push:
branches: [main]
pull_request:
jobs:
install-and-test:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Resolve pnpm store directory
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- uses: WarpBuilds/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm -r run typecheck
- run: pnpm -r run testOrder matters in that job. The cache: pnpm input resolves the store by asking pnpm itself, so pnpm/action-setup has to run before the setup action or the workflow fails on the cache step. The explicit store resolution step is useful even when the setup action handles the cache, because steps.pnpm-store.outputs.path gives you a value to print in logs and to reuse in a later prune or size check.
cache-dependency-path accepts globs and multiple paths. A single root pnpm-lock.yaml is the normal case for a workspace, since pnpm resolves every package in pnpm-workspace.yaml into that one lockfile. Point it at the lockfile rather than at package.json, or every script or metadata edit invalidates a store that was perfectly good. The full input list for each fork is in the setup actions documentation.
When you want direct control over the key and its fallbacks, use WarpBuilds/cache, a drop-in replacement for actions/cache@v4:
- name: Restore pnpm store
uses: WarpBuilds/cache@v1
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-Four cache behaviors are worth knowing before you rely on either form. Entries are scoped to the key, the version, and the branch, so a store saved on a feature branch is separate from the one on the default branch; seed the cache on the default branch and let branch builds reach it through restore-keys. The version hash covers the compression tool and the cached paths, so a store saved on a macOS runner cannot restore on a Linux runner even under an identical key. Entries expire 7 days after last use, so active repositories stay warm and abandoned branches release their storage without a cleanup job. Cache metering is small enough to state exactly: $0.20 per GB-month of storage and $0.0001 per write or restore operation on hosted runners, and free on BYOC.
Sizing
WarpBuild Linux x64 runners carry 4GB of memory per vCPU across the range. These are the sizes a pnpm workspace usually picks from, taken from the cloud runners catalog:
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 |
A workspace rarely wants one size for everything, and --filter is the lever that lets each package get its own. pnpm --filter <name> selects a package, the trailing ellipsis form --filter <name>... adds everything that package depends on, and the leading form --filter ...<name> adds everything that depends on it. Install with the same filter you build with, and the install only fetches the subgraph that job needs.
That maps directly onto a matrix where the runner label is a matrix value:
package:
strategy:
fail-fast: false
matrix:
include:
- name: "@acme/web"
runner: warp-ubuntu-latest-x64-16x
- name: "@acme/api"
runner: warp-ubuntu-latest-x64-8x
- name: "@acme/cli"
runner: warp-ubuntu-latest-x64-4x
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: WarpBuilds/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: pnpm install --frozen-lockfile --filter ${{ matrix.name }}...
- run: pnpm --filter ${{ matrix.name }} run build
- run: pnpm --filter ${{ matrix.name }} run testPick each row by what the package can actually use.
Install-dominated packages stay on 4x. Install is network and disk work, pnpm defaults to 16 concurrent network requests, and a warm store turns most of the step into hardlink creation. Extra cores change little here, so a package whose job is mostly pnpm install plus a lint pass belongs on the smaller row.
Typecheck runs tsc on one core, which makes memory the constraint rather than parallelism. The 16GB on 4x covers most packages, and a package whose project references pull in the whole graph moves to 8x for 32GB before anyone reaches for a larger heap flag on a machine that cannot back it.
Bundle and test are where cores pay. esbuild and swc use every core they are given, and Vitest sizes its worker pool from the core count, so an application package with a large test suite is the one that earns 8x or 16x. Keep the leaf libraries on 4x in the same matrix.
At the root, pnpm -r runs tasks across packages with a default workspace concurrency of 4. Raise --workspace-concurrency toward the vCPU count when each package task is single threaded, and lower it when each task spawns its own worker pool, since two levels of parallelism on the same cores produce contention rather than throughput.
Worked cost model
GitHub publishes per-minute list prices for its hosted runners in the Actions minute multipliers reference. Checked on 2026-08-13, the 4-core Linux larger runner is $0.012 per minute and the 8-core Linux larger runner is $0.022 per minute.
Take a workspace running 1,500 workflow runs a month with two filtered jobs per run: a lint and typecheck job averaging 4 minutes on 4 vCPU, and a build and test job averaging 7 minutes on 8 vCPU. That is 6,000 minutes at the smaller size and 10,500 minutes at the larger one.
| Job | Minutes per month | GitHub-hosted rate | GitHub-hosted cost | WarpBuild rate | WarpBuild cost |
|---|---|---|---|---|---|
| Lint and typecheck, 4 vCPU | 6,000 | $0.012 | $72.00 | $0.008 | $48.00 |
| Build and test, 8 vCPU | 10,500 | $0.022 | $231.00 | $0.016 | $168.00 |
| Store cache, 2GB and 4,500 operations | n/a | n/a | n/a | see above | $0.85 |
| Total | 16,500 | n/a | $303.00 | n/a | $216.85 |
Stated as list-price arithmetic: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), a 33 percent lower list price, and warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), a 27 percent lower list price. GitHub list prices checked on 2026-08-13.
Rates for every size and platform are on the pricing page.
Bottlenecks
Three problems account for most slow pnpm pipelines on GitHub Actions.
A cold content-addressable store. With no restored store, every job resolves the lockfile and downloads every tarball before any package can be linked, and a workspace lockfile with several thousand entries turns that into minutes of network time on every job in the matrix. Read the install log to confirm the fix landed: a warm run reports packages as reused rather than downloaded, and the resolution phase finishes in seconds. When only one job in a matrix runs cold, check whether it landed on a different branch scope or a different operating system, since both produce a separate cache entry. The same store-over-node_modules reasoning applies to every JavaScript package manager, and the short version lives in the answer on how to cache node_modules in GitHub Actions.
Lockfile churn invalidating the whole store key. A key built from hashFiles('pnpm-lock.yaml') moves whenever any dependency in any workspace package moves. One bump to one leaf library therefore produces a key that has never been seen, and every job in the matrix starts from an empty store on exactly the pull request where the cache matters most. The restore-keys prefix in the explicit form above is the fix: the action falls back to the most recent store under the same prefix, and the install fetches only the versions that actually changed. Automated dependency update pull requests are the workload that makes this visible, because they change the lockfile every single run.
Postinstall scripts. Packages with native components run build steps at install time, and those steps repeat on every cold install. pnpm 10 blocks dependency lifecycle scripts by default and requires each one to be approved, which makes the cost explicit: the approved list is the set of packages you are paying to build. pnpm can keep the results of those build steps in the store through its side effects cache, so a restored store carries the built artifacts as well as the sources. Two habits keep that working. Pin the Node version in the workflow so the ABI behind any compiled addon stays stable across runs, and keep the approved build list as short as the workspace actually needs.
When a job is slow and the cause is unclear, WarpBuild CI observability correlates system metrics from the runner agent with GitHub Actions job logs, which separates a CPU-bound test phase from an install stuck on network resolution. The Action Debugger pauses a workflow and opens an SSH session on the runner, which is the fastest way to run pnpm store path and pnpm store status against the machine that produced the failure. Snapshot runners and remote Docker builders cover the neighboring cases where a workspace also builds images.
Proof
Public repositories running warp- labels are citable evidence you can read yourself. The Trigger.dev end-to-end suite is a pnpm workspace whose matrix runs on warp-ubuntu-latest-x64-4x and warp-windows-latest-x64-8x, and it installs with a workspace filter, pnpm install --frozen-lockfile --filter trigger.dev..., exactly as described above. The workflow is triggerdotdev/trigger.dev e2e.yml, checked on 2026-08-13.
FAQ
What should the pnpm cache key hash?
pnpm-lock.yaml. Point cache-dependency-path at it so the key rolls exactly when the resolved dependency set changes, and add a restore-keys prefix on the explicit cache action so a single dependency bump restores the previous store instead of starting cold.
Do I still need pnpm/action-setup when I use WarpBuilds/setup-node?
Yes, and it has to run first. The cache: pnpm input shells out to pnpm to resolve the store directory, so a workflow that runs setup-node before pnpm exists fails on the cache step rather than on the install.
How do I run only the workspace packages that changed?
Use a changed-since filter such as pnpm --filter '...[origin/main]' run build, and set fetch-depth: 0 on actions/checkout so the merge base exists. Without the full history the filter sees no base commit and selects every package.
What changes when a pnpm workflow moves to WarpBuild runners?
The runs-on label per job, and the setup action reference if you want cache steps served by WarpBuild Cache.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.