Skip to main content
Back to Blog
Guide
2026-07-16

Cache the pnpm Store in GitHub Actions: A Faster CI Guide

Learn how to cache pnpm store GitHub Actions dependencies, cut install time, preserve lockfile correctness, and diagnose cache misses in test CI.

Cache the pnpm Store in GitHub Actions: A Faster CI Guide

A UI test job that spends four minutes downloading packages before running a six-minute Playwright suite is paying the same setup tax on every commit. Caching the pnpm store in GitHub Actions removes much of that repeated network work without caching the installed dependency tree itself. That distinction matters for QA repositories: the workflow stays reproducible from the lockfile, while repeated jobs can reuse package archives already fetched by pnpm.

This guide builds a practical cache setup, explains what the cache key protects, and shows how to prove the cache improves the test pipeline. The examples apply to API, component, and end-to-end test projects, including pnpm workspaces.

Understand what belongs in the cache

pnpm keeps a content-addressable store of package files. A project installation links packages from that store into its dependency layout. In CI, the useful reusable layer is the store, not node_modules. Restoring the store lets pnpm install --frozen-lockfile rebuild the project-specific links while avoiding many downloads.

The lockfile remains the source of truth. A cache hit does not authorize a package that is absent from pnpm-lock.yaml, and frozen installation fails if the manifest and lockfile disagree. This gives test teams speed without turning the cache into an undeclared dependency source.

CandidateCache it?Reason for a test repository
pnpm storeYesReuses package content while installation still follows the lockfile
node_modulesUsually noContains project links and lifecycle results that are less portable across environments
pnpm-lock.yamlNo, commit itIt defines the resolved dependency graph and should be version controlled
Playwright browser binariesSeparate decisionThey are large runtime assets with a lifecycle different from JavaScript packages
Test reportsNoUpload them as artifacts so results remain associated with a specific run

The store location is not something to guess. Ask pnpm in the runner environment:

pnpm store path

That command is also useful when debugging a job. It reveals the exact directory that the cache step must save and restore.

Start with a correct uncached test job

Before adding a cache, make the job deterministic. Pin the Node.js major version your repository supports, enable pnpm through its official setup action, install with the frozen lockfile behavior, and run the same test script developers use locally.

name: test

on:
  pull_request:
  push:
    branches: [main]

jobs:
  api-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 10
          run_install: false
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: pnpm install --frozen-lockfile
      - run: pnpm test

The example uses documented action inputs, but the Node and pnpm versions should match the repository's supported toolchain. If the project uses the packageManager field and Corepack instead, keep that existing policy. Do not introduce a different package-manager version only to add caching.

Run the uncached job several times and record the duration of the install step. A single run is noisy because GitHub-hosted runner network and CPU conditions vary. The comparison you care about is repeated median install time, not the fastest screenshot you can capture.

Add setup-node caching with a lockfile-aware key

The shortest supported configuration is the cache integration in actions/setup-node. Set cache: 'pnpm'. The action uses the package manager's dependency file to create its cache key, and it does not cache node_modules.

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'
          cache-dependency-path: pnpm-lock.yaml
      - run: pnpm install --frozen-lockfile

Keep the pnpm setup step before the Node setup step so the pnpm executable is available when cache-related logic needs it. On the first run for a new lockfile, expect a miss and a normal download. After the successful job completes, later runs with the same relevant inputs can restore the cache.

The key consequence for QA work is easy to test:

Repository changeExpected cache behaviorTest implication
Test spec onlyExisting store can be restoredDependency setup should be faster
pnpm-lock.yaml changesExact prior key no longer matchesNew or updated packages must be fetched
Node version changesTreat as a new environmentRe-measure install and native dependency behavior
Test command changesStore can still be reusedOnly execution behavior changes

Changing a Playwright assertion should not invalidate several gigabytes of package content. Changing the lockfile should alter the exact key because the dependency graph has changed. This balance is the reason to use the action's supported package-manager cache rather than inventing a broad key based only on the branch name.

Handle pnpm workspaces and multiple lockfiles deliberately

Most pnpm monorepos have one lockfile at the repository root. In that case, point cache-dependency-path to the root pnpm-lock.yaml, run installation at the root, and use filtered commands for individual QA packages.

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'
          cache-dependency-path: pnpm-lock.yaml
      - run: pnpm install --frozen-lockfile
      - name: Run contract tests
        run: pnpm --filter @acme/contract-tests test
      - name: Run browser tests
        run: pnpm --filter @acme/e2e test

If independent subprojects truly have separate lockfiles, provide the dependency paths supported by the action and verify every relevant lockfile contributes to invalidation. Do not point the cache at a package manifest while ignoring the resolved lockfiles. A manifest can contain a range, while the lockfile records the actual resolution used by CI.

Workspace filters also prevent a common timing mistake. Cache restoration may make installation quick, but it does not make an unnecessarily broad test command selective. Keep dependency caching, test selection, and job parallelism as separate improvements. Measure each one so a faster install is not incorrectly credited for fewer executed tests.

Keep browser assets and reports out of the store decision

Playwright's npm package and its browser binaries are different layers. The package can benefit from the pnpm store cache. Browser downloads use their own location and compatibility rules. If you cache browser binaries, use a separately reasoned key that accounts for the Playwright version and runner platform, or install them normally with the documented Playwright command.

pnpm exec playwright install --with-deps chromium
pnpm exec playwright test

Likewise, JUnit XML, HTML reports, screenshots, videos, and traces belong to the current run. Publish them as artifacts even when a test fails. They should never reappear from a dependency cache and be mistaken for new evidence.

If long browser jobs pile up after every force-push, caching addresses only setup time. Pair it with a concurrency policy from canceling stale E2E runs on a new commit. If the problem is interpreting unstable results rather than runtime, use a consistent report pipeline such as GitLab CI JUnit reporting for flaky tests, adapted to the CI system you operate.

Prove the cache works without weakening installation

A healthy verification sequence uses two runs from the same lockfile, then one run after a controlled dependency update. Keep --frozen-lockfile enabled throughout. Removing correctness checks to make the cached path pass defeats the purpose.

Add lightweight diagnostics temporarily:

      - name: Show package-manager context
        run: |
          node --version
          pnpm --version
          pnpm store path
      - run: pnpm install --frozen-lockfile

Inspect the setup action's log for cache restoration information and compare install durations. Then update a development dependency through the normal repository process, commit the changed lockfile, and confirm the job performs the needed fetches. Finally, rerun that commit and check that reuse returns.

For stronger evidence, collect these values for at least five ordinary pull requests:

SignalHealthy resultWarning sign
Median install durationMeaningfully lower after warm-upNo change across repeated identical lockfiles
Frozen install resultAlways succeeds or reports a real mismatchWorkflow removes the flag to hide failure
Test countSame before and after cacheFaster run caused by skipped suites
Report freshnessArtifacts belong to current runOld reports appear after cache restoration

Caching should change time spent acquiring package content. It should not change which tests execute, which versions resolve, or whether failures block the job.

Diagnose misses and misleading wins

When every run misses, first compare the environment and dependency-path inputs. A changed lockfile, runner operating system, package-manager version, or cache scope can legitimately prevent an exact reuse. Confirm the lockfile exists at the path evaluated by the workflow. In a monorepo, remember that GitHub Actions starts steps at the repository root unless a working directory says otherwise.

When the log reports a restore but installation stays slow, profile the install rather than declaring the cache broken. Lifecycle scripts, native builds, post-install downloads, and creation of project links still take time. The store cache reduces fetch work, not every operation performed by pnpm install.

When a workflow becomes flaky only after cache introduction, compare the cached and uncached paths with the same committed lockfile. Do not add a vague timestamp key as a permanent fix. That merely disables reuse. Find whether a package runs an environment-sensitive install script, whether a separately cached runtime asset is stale, or whether the job previously depended on undeclared state.

A useful emergency check is to change the cache configuration in a branch and run one clean installation, without changing the test command. Once the cause is understood, restore a stable, lockfile-derived policy.

Frequently Asked Questions

Should I cache node_modules for pnpm in GitHub Actions?

Usually, cache the pnpm store instead. The store contains reusable package content, while node_modules contains links and installation results shaped by the project and environment. Rebuilding those links from a committed lockfile is a useful correctness step. If a team chooses to cache installed dependencies, it needs stronger invalidation for operating system, runtime, lockfile, and install behavior, plus evidence that the extra complexity produces a real gain.

Does a pnpm cache hit mean pnpm install can be skipped?

No. Run pnpm install --frozen-lockfile on both hit and miss paths. The cache supplies reusable package content, but installation validates the lockfile and constructs the dependency layout required by the job. Skipping installation can leave the workspace without links or can reuse state that does not represent the current commit. The fast path should be the same correct command with less downloading.

Why is the first GitHub Actions run still slow?

A new cache key has nothing to restore. The first successful run downloads packages, completes installation, and makes cache content available for later matching runs. A lockfile update can create another cold run because the dependency graph changed. Judge the setup with repeated runs of the same lockfile, then include normal lockfile changes in the longer-term measurement so expectations reflect real development activity.

Can this cache replace Playwright browser installation?

No. The pnpm store covers package content managed by pnpm. Playwright browser executables are separate runtime assets, and Linux system dependencies are handled separately as well. Keep browser provisioning explicit. If browser download time is material, evaluate its own cache or a tested container image with compatible browsers, but do not assume the package-store cache has preserved those binaries.