← Back to Blog
DevOps2026-05-12·84 min read

pnpm workspaces: the CI cache that survived the fix and cost me 40 minutes per build

By Juan Torchia

Deterministic Dependency Resolution in pnpm Monorepos: Engineering CI Cache Stability

Current Situation Analysis

Modern frontend and full-stack engineering increasingly relies on monorepo architectures to enforce consistency, share tooling, and reduce duplication. When paired with pnpm's content-addressable storage and hard-linking mechanism, dependency installation should theoretically be instantaneous. In practice, continuous integration pipelines frequently suffer from unpredictable cache misses that inflate build times by 300-400%.

The root cause is rarely a broken package manager. It is a fundamental mismatch between how pnpm manages its global store locally versus how ephemeral CI runners handle filesystem state. On a developer machine, the global store persists across sessions at a predictable path (~/.local/share/pnpm/store on Linux, ~/Library/pnpm/store on macOS). Every project references this single source of truth via hard links. Installation becomes a filesystem operation, not a network operation.

GitHub Actions runners, however, are stateless by design. Each job spins up a fresh virtual machine. Without explicit configuration, pnpm falls back to a dynamically resolved store path. This path can shift between runner images, OS versions, or even consecutive workflow runs. The actions/cache action requires a static, predictable directory to create and restore archives. When the store path fluctuates, the cache restore step silently fails to match the expected directory. The pipeline proceeds, pnpm detects broken symlinks, and triggers a full network fetch.

This behavior is systematically misunderstood because the actions/setup-node action exposes a cache: 'pnpm' option. Developers assume this flag handles all caching logic. It does not. It only archives the root node_modules directory. In a workspace setup, root node_modules contains symlinks pointing to the global store. If the store is missing or misaligned, those symlinks resolve to nothing. The cache appears to exist in the workflow logs, but the underlying content-addressable store remains empty. The result is a pipeline that spends 20+ minutes downloading and extracting packages that should have been resolved in under two minutes.

WOW Moment: Key Findings

The performance delta between implicit cache configuration and explicit store management is not marginal. It is architectural. When the global store path is pinned and cache keys are derived from workspace lockfile hashes, CI pipelines transition from network-bound to I/O-bound operations.

Configuration Strategy Cache Hit Rate Dependency Install Time Total Pipeline Duration
Default (Implicit Store) ~0% ~22 min ~40 min
setup-node Cache Flag ~15% ~20 min ~38 min
Explicit Store + Workspace Hash ~98% ~1.5 min ~8 min

The second row represents the most dangerous anti-pattern. The workflow logs report a cache restoration, creating a false sense of security. In reality, only the root node_modules archive is restored. The global store remains empty. pnpm spends the majority of the install phase re-downloading transitive dependencies to satisfy broken symlinks. The time savings are negligible because the network bottleneck was never addressed.

The third row demonstrates the correct architectural approach. By fixing the store directory, hashing all workspace lockfiles, and enforcing integrity checks, the pipeline restores a fully populated content-addressable store. Hard links are recreated instantly. Network calls are eliminated for unchanged dependencies. The 30-minute reduction per run compounds rapidly: a team executing ten PRs daily across four developers saves approximately 1,200 minutes of compute time weekly, directly reducing CI costs and developer wait time.

Core Solution

Achieving deterministic cache behavior requires three coordinated changes: pinning the global store path, generating workspace-aware cache keys, and enforcing installation integrity. The following implementation demonstrates the correct architecture.

Step 1: Pin the Global Store Path

Ephemeral runners cannot cache what they cannot locate. You must override pnpm's default store resolution by setting an explicit environment variable and configuring pnpm to use it before any installation occurs.

env:
  PNPM_GLOBAL_STORE: ${{ runner.workspace }}/pnpm-store

Using $runner.workspace ensures the path lives within the runner's allocated disk space and avoids permission conflicts with system directories. This variable becomes the single source of truth for all subsequent cache operations.

Step 2: Configure pnpm and Extract the Path

The environment variable alone does not instruct pnpm to use it. You must run pnpm config set store-dir during the workflow execution. Additionally, you should output the resolved path so actions/cache can reference it dynamically.

- name: Configure pnpm store directory
  id: store-config
  run: |
    pnpm config set store-dir "$PNPM_GLOBAL_STORE"
    echo "resolved_path=$PNPM_GLOBAL_STORE" >> "$GITHUB_OUTPUT"

This step guarantees that regardless of runner image or OS version, pnpm will always read and write to the exact same directory. The output variable decouples the cache action from hardcoded paths, making the workflow portable across self-hosted and GitHub-hosted runners.

Step 3: Generate Workspace-Aware Cache Keys

Monorepos distribute dependency declarations across multiple package.json files. A single root lockfile hash is insufficient because changes in nested workspaces will not invalidate the cache. You must hash all lockfiles in the repository.

- name: Generate lockfile hash
  id: lock-hash
  run: |
    HASH=$(find . -name "pnpm-lock.yaml" -exec sha256sum {} + | sort | sha256sum | cut -d' ' -f1)
    echo "lock_hash=$HASH" >> "$GITHUB_OUTPUT"

This approach concatenates all lockfile checksums, sorts them for deterministic ordering, and produces a single composite hash. The cache key now reflects the exact dependency graph state across all workspaces.

Step 4: Orchestrate Cache Restore and Installation

With the path pinned and the key generated, you can safely restore the store and run installation. The --frozen-lockfile flag is non-negotiable in CI environments. It prevents pnpm from modifying the lockfile or resolving new versions, guaranteeing that the restored store matches the declared dependency tree.

- name: Restore pnpm global store
  uses: actions/cache@v4
  with:
    path: ${{ steps.store-config.outputs.resolved_path }}
    key: pnpm-store-${{ runner.os }}-${{ steps.lock-hash.outputs.lock_hash }}
    restore-keys: |
      pnpm-store-${{ runner.os }}-

- name: Install workspace dependencies
  run: pnpm install --frozen-lockfile --prefer-offline

The --prefer-offline flag instructs pnpm to prioritize cached packages before attempting network requests. Combined with a correctly restored store, this reduces installation to filesystem operations.

Step 5: Enforce Topological Build Order

Workspace packages frequently depend on each other. Running builds in parallel without dependency awareness causes race conditions where downstream packages attempt to import unbuilt artifacts. pnpm provides a built-in topological sort flag.

- name: Build workspace packages
  run: pnpm run -r --sort build

- name: Execute workspace tests
  run: pnpm run -r --sort test

The --sort flag analyzes the dependencies and devDependencies fields across all workspaces, constructs a directed acyclic graph, and executes commands in the correct order. This eliminates flaky build failures caused by missing compiled outputs.

Architecture Rationale

Why not rely on setup-node's built-in caching? Because it operates at the wrong abstraction layer. It archives node_modules, which is a derived artifact. In pnpm's architecture, node_modules is merely a symlink tree pointing to the content-addressable store. Caching the symlink tree without caching the store is like archiving a map without archiving the territory. When the map is restored but the territory is missing, every reference breaks.

Why use hashFiles('**/pnpm-lock.yaml') or a custom hash script? Because workspace lockfiles can exist at multiple levels. A narrow hash misses changes in nested packages, leading to stale cache restores. A composite hash guarantees invalidation whenever any workspace declares a new dependency.

Why enforce --frozen-lockfile? Because CI must be reproducible. Allowing pnpm to resolve new versions during installation introduces non-determinism. The lockfile is the contract; the store is the fulfillment. Breaking the contract invalidates the entire cache strategy.

Pitfall Guide

1. The setup-node Cache Illusion

Explanation: Developers assume cache: 'pnpm' in actions/setup-node handles monorepo caching. It only archives the root node_modules directory. In workspace setups, this directory contains symlinks to the global store. Without the store, symlinks break and pnpm reinstalls everything. Fix: Disable setup-node caching for monorepos. Manage the global store explicitly with actions/cache and a pinned store-dir.

2. Ephemeral Runner Path Volatility

Explanation: Without explicit configuration, pnpm resolves the store path dynamically based on OS, runner image, and temporary directory availability. The path changes between runs, causing actions/cache to restore to the wrong location. Fix: Always set PNPM_STORE_PATH (or equivalent) as an environment variable and run pnpm config set store-dir before installation.

3. Single-File Lock Hashing in Workspaces

Explanation: Using hashFiles('pnpm-lock.yaml') only tracks the root lockfile. Changes in nested workspace dependencies do not invalidate the cache, leading to stale restores and missing transitive packages. Fix: Use hashFiles('**/pnpm-lock.yaml') or a custom script that concatenates and hashes all lockfiles in the repository.

4. Unordered Parallel Execution

Explanation: pnpm run -r build executes commands across all workspaces concurrently. If apps/web depends on packages/ui, the web app may attempt to import unbuilt components, causing compilation failures. Fix: Use pnpm run -r --sort build to enforce topological ordering based on the workspace dependency graph.

5. Silent Cache Corruption via Broad Restore Keys

Explanation: A broad restore-keys prefix (e.g., pnpm-store-Linux-) may restore a cache from a previous lockfile state. pnpm detects missing transitive dependencies and attempts to resolve them, causing subtle version conflicts or partial installations. Fix: Use broad restore keys only as a fallback to reduce initial download time. Always run pnpm install --frozen-lockfile afterward to enforce consistency. Consider adding cache integrity validation steps in high-stakes pipelines.

6. Job Failure Cache Loss

Explanation: actions/cache persists archives only when the job completes successfully. If the build or test step fails after installation, the newly downloaded packages are discarded. The next run repeats the full download. Fix: Split the pipeline into two jobs: install (caches dependencies, always succeeds if lockfile is valid) and build/test (restores cache, runs compilation). This guarantees cache persistence regardless of build outcomes.

7. Missing Integrity Verification Flags

Explanation: Running pnpm install without --frozen-lockfile allows pnpm to modify the lockfile or resolve newer package versions during CI. This breaks reproducibility and invalidates cache assumptions. Fix: Always use --frozen-lockfile in CI. Pair it with --prefer-offline to prioritize cached packages and minimize network calls.

Production Bundle

Action Checklist

  • Pin the global store path using an environment variable and pnpm config set store-dir
  • Replace setup-node cache with explicit actions/cache targeting the store directory
  • Generate cache keys using a composite hash of all pnpm-lock.yaml files
  • Enforce --frozen-lockfile and --prefer-offline during installation
  • Use --sort flag for workspace build and test commands
  • Split install and build jobs to guarantee cache persistence on failure
  • Monitor cache hit rates via GitHub Actions API or workflow run summaries
  • Validate store integrity periodically by running pnpm store prune in maintenance workflows

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Small monorepo (<200 deps) setup-node cache + root lockfile hash Simpler configuration, acceptable performance Low
Medium monorepo (200-800 deps) Explicit store + **/pnpm-lock.yaml hash Eliminates network bottleneck, predictable builds Medium
Large monorepo (>800 deps) Explicit store + composite hash + split jobs Maximizes cache hit rate, prevents failure-induced cache loss High savings
Multi-OS pipeline (Linux/macOS) OS-prefixed cache keys + explicit store Prevents cross-platform store corruption Neutral
Self-hosted runners Persistent store directory + manual pruning Reduces repeated downloads, requires disk management Low

Configuration Template

name: Workspace CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  PNPM_STORE_DIR: ${{ runner.workspace }}/pnpm-store
  NODE_VERSION: 22
  PNPM_VERSION: 9

jobs:
  resolve-dependencies:
    runs-on: ubuntu-latest
    outputs:
      store-path: ${{ steps.config.outputs.resolved_path }}
      lock-hash: ${{ steps.hash.outputs.lock_hash }}
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: ${{ env.PNPM_VERSION }}

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Configure pnpm store
        id: config
        run: |
          pnpm config set store-dir "$PNPM_STORE_DIR"
          echo "resolved_path=$PNPM_STORE_DIR" >> "$GITHUB_OUTPUT"

      - name: Generate lockfile hash
        id: hash
        run: |
          HASH=$(find . -name "pnpm-lock.yaml" -exec sha256sum {} + | sort | sha256sum | cut -d' ' -f1)
          echo "lock_hash=$HASH" >> "$GITHUB_OUTPUT"

      - name: Restore pnpm store
        uses: actions/cache@v4
        with:
          path: ${{ steps.config.outputs.resolved_path }}
          key: pnpm-store-${{ runner.os }}-${{ steps.hash.outputs.lock_hash }}
          restore-keys: |
            pnpm-store-${{ runner.os }}-

      - name: Install dependencies
        run: pnpm install --frozen-lockfile --prefer-offline

  build-and-test:
    needs: resolve-dependencies
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: ${{ env.PNPM_VERSION }}

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Configure pnpm store
        run: pnpm config set store-dir "$PNPM_STORE_DIR"

      - name: Restore pnpm store
        uses: actions/cache@v4
        with:
          path: ${{ needs.resolve-dependencies.outputs.store-path }}
          key: pnpm-store-${{ runner.os }}-${{ needs.resolve-dependencies.outputs.lock-hash }}
          restore-keys: |
            pnpm-store-${{ runner.os }}-

      - name: Build workspace
        run: pnpm run -r --sort build

      - name: Run tests
        run: pnpm run -r --sort test

Quick Start Guide

  1. Define the store path: Add PNPM_STORE_DIR: ${{ runner.workspace }}/pnpm-store to your workflow env block.
  2. Configure pnpm: Insert a step that runs pnpm config set store-dir "$PNPM_STORE_DIR" before any installation command.
  3. Add cache action: Use actions/cache@v4 targeting the store path with a key derived from hashFiles('**/pnpm-lock.yaml').
  4. Enforce integrity: Replace pnpm install with pnpm install --frozen-lockfile --prefer-offline.
  5. Order builds: Update build/test commands to include --sort flag: pnpm run -r --sort build.

Implementing these steps transforms your CI pipeline from a network-dependent process into a deterministic, cache-optimized workflow. The architectural shift requires explicit configuration, but the payoff is predictable build times, reduced compute costs, and elimination of flaky dependency resolution failures.