pnpm workspaces: el caché de CI que sobrevivió al fix y me costó 40 minutos de build
Deterministic CI Pipelines: Mastering pnpm Store Caching in Monorepo Workflows
Current Situation Analysis
Monorepo architectures have become the standard for modern frontend and full-stack engineering, yet dependency resolution in continuous integration remains a persistent bottleneck. The industry pain point is not the package manager itself, but the mismatch between local development assumptions and ephemeral CI runner behavior. Developers routinely configure GitHub Actions workflows using default templates, assuming that built-in caching mechanisms will automatically optimize dependency installation. In practice, this assumption creates silent cache degradation that inflates pipeline duration by 300-500%.
The root cause is architectural: pnpm relies on a global content-addressable store to manage dependencies via hard links and symlinks. On a developer's machine, this store persists across sessions (~/.local/share/pnpm/store on Linux, ~/Library/pnpm/store on macOS). Every project on the system references the same physical files. When a package is already present, resolution is instantaneous. CI runners, however, are stateless. Each execution provisions a fresh filesystem. Without explicit configuration, pnpm falls back to dynamic or temporary directory resolution. The cache key generated by GitHub Actions may match, but the restoration target path drifts between runs. The cache exists in GitHub's storage layer, but pnpm never queries it because the lookup directory changed.
This problem is systematically overlooked because the actions/setup-node action includes a cache: 'pnpm' option that appears functional at first glance. It successfully caches the root node_modules directory, but it completely ignores the global store. In a workspace setup, each package maintains its own node_modules containing symlinks pointing to the store. When the store is missing or misaligned, those symlinks resolve to null references. pnpm detects the broken graph and triggers a full resolution and download cycle. The pipeline reports cache hits in the UI, yet the installation step downloads every package from the registry.
Empirical data from production monorepos confirms the severity. In a repository containing approximately 850 transitive dependencies across three workspaces, a misconfigured cache strategy yields installation times near 20 minutes and total pipeline durations exceeding 35 minutes. When the store path is explicitly pinned and cached correctly, installation drops to under two minutes, and total CI time stabilizes around eight minutes. The discrepancy is not marginal; it directly impacts developer feedback loops, cloud compute costs, and release velocity.
WOW Moment: Key Findings
The most critical insight is that cache configuration in pnpm workspaces is not about caching node_modules directories. It is about caching the content-addressable store itself. The following comparison demonstrates the operational impact of three distinct caching strategies under identical workload conditions.
| Approach | Install Duration | Total Pipeline Time | Cache Hit Rate |
|---|---|---|---|
| No Cache (Registry Fetch) | ~22 min | ~40 min | 0% |
setup-node Cache Only |
~20 min | ~38 min | ~15% |
| Explicit Store Path + Workspace Hash | ~1.5 min | ~8 min | ~95% |
The second row represents the most dangerous scenario. The workflow appears to cache dependencies, and GitHub Actions reports partial restoration. However, because only the root node_modules is preserved while the store remains absent, pnpm still resolves and downloads the majority of packages. The pipeline saves roughly two minutes, creating a false sense of optimization.
The third row demonstrates deterministic caching. By pinning the store directory, hashing all workspace lockfiles, and restoring the exact path before installation, the pipeline achieves near-instant resolution. This enables parallel job execution, reduces runner compute costs, and eliminates the primary source of CI flakiness in monorepo environments.
Core Solution
Implementing reliable pnpm caching requires shifting from implicit behavior to explicit state management. The architecture must address four distinct concerns: store path determinism, cache key precision, topological execution order, and cache persistence guarantees.
Step 1: Pin the Store Directory
Ephemeral runners cannot rely on OS-default paths. The store must be anchored to a predictable location that survives across job steps. Define an environment variable at the workflow level and instruct pnpm to use it before any installation occurs.
env:
CI_PNPM_STORE: ${{ github.workspace }}/.pnpm-store
Using a workspace-relative path ensures the directory is created within the runner's execution context. Alternatively, a home-directory path (~/.pnpm-store) works equally well. The critical requirement is consistency.
Step 2: Configure pnpm Before Installation
Defining the variable is insufficient. pnpm must be explicitly told to route all store operations to that path. This configuration step must execute before pnpm install.
- name: Configure pnpm store routing
run: |
pnpm config set store-dir "${CI_PNPM_STORE}"
echo "Store routed to: $(pnpm config get store-dir)"
This step eliminates path drift. Every subsequent pnpm command will reference the same directory, regardless of runner image or execution context.
Step 3: Implement Workspace-Aware Cache Keying
Monorepos frequently contain multiple lockfiles. A cache key that only hashes the root pnpm-lock.yaml will miss dependency changes in nested workspaces, causing stale restores or unnecessary cache misses. The key must aggregate hashes across all workspace boundaries.
- name: Generate dependency hash
id: dep-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 hash. The resulting key changes only when actual dependency graphs change.
Step 4: Restore and Persist the Store
Use actions/cache to manage the store directory. Pair the precise hash with a fallback prefix to handle partial updates without breaking resolution.
- name: Restore pnpm store
uses: actions/cache@v4
with:
path: ${{ env.CI_PNPM_STORE }}
key: pnpm-store-${{ runner.os }}-${{ steps.dep-hash.outputs.lock-hash }}
restore-keys: |
pnpm-store-${{ runner.os }}-
The restore-keys fallback allows GitHub Actions to retrieve the most recent store snapshot when the exact hash does not match. This reduces download volume during incremental updates.
Step 5: Enforce Topological Execution
Workspace packages often 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 that respects the workspace graph.
- name: Compile workspaces
run: pnpm run -r --sort build
The --sort flag serializes execution according to dependency order. For repositories with heavy build steps, combine it with --workspace-concurrency to control parallelism without breaking the graph.
Step 6: Decouple Installation from Build/Test Jobs
GitHub Actions only uploads cache artifacts when a job completes successfully. If a build or test step fails, the newly resolved store is discarded. The next pipeline run repeats the full download cycle. Separating dependency resolution into a dedicated job guarantees cache persistence regardless of downstream failures.
jobs:
resolve-deps:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
# Cache restore, config, and install steps here
- name: Install dependencies
run: pnpm install --frozen-lockfile
compile-and-test:
needs: resolve-deps
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
# Cache restore and build/test steps here
This architecture isolates state management from execution logic. The resolve-deps job focuses exclusively on cache restoration and installation. Once successful, the store is persisted. Subsequent jobs restore the cache and proceed to compilation and testing.
Pitfall Guide
1. The setup-node Cache Illusion
Explanation: The cache: 'pnpm' parameter in actions/setup-node only archives the root node_modules directory. It does not interact with pnpm's global store. In workspace configurations, each package maintains symlinks pointing to the store. When the store is absent, symlinks break, and pnpm triggers a full registry fetch.
Fix: Disable cache: 'pnpm' in setup-node. Manage store caching manually using actions/cache with an explicit store-dir configuration.
2. Topological Build Collisions
Explanation: pnpm run -r build executes tasks in parallel by default. When workspace A depends on workspace B, parallel execution causes A to import unbuilt artifacts from B, resulting in missing module errors or stale type definitions.
Fix: Append the --sort flag to respect the dependency graph. For large repositories, combine with --workspace-concurrency=3 to balance speed and correctness.
3. Stale Restore Fallbacks Without Frozen Lockfiles
Explanation: Broad restore-keys prefixes allow GitHub Actions to return partially outdated stores. If pnpm resolves packages from a stale store while the lockfile has changed, transitive dependencies may mismatch, causing runtime errors or type mismatches.
Fix: Always pair fallback keys with pnpm install --frozen-lockfile. The flag forces pnpm to validate the store against the exact lockfile state, rejecting mismatched resolutions.
4. Cache Persistence on Job Failure
Explanation: actions/cache uploads artifacts only after a job exits with status 0. If a test suite fails or a build step crashes, the newly resolved store is discarded. The next pipeline run repeats the entire download process.
Fix: Split the pipeline into a dedicated installation job and separate execution jobs. The installation job succeeds independently, guaranteeing cache persistence.
5. Implicit Store Path Drift
Explanation: Relying on OS-default paths (~/.local/share/pnpm/store) causes mismatches across runner images, containerized environments, or self-hosted runners with different filesystem layouts. The cache key matches, but the restoration target differs.
Fix: Explicitly set store-dir via pnpm config set or commit a .npmrc file with store-dir=.pnpm-store. Verify the resolved path in pipeline logs before installation.
6. Ignoring Workspace-Specific Lockfiles
Explanation: Caching only the root pnpm-lock.yaml misses dependency updates in nested workspaces. The cache key remains unchanged while the actual dependency graph diverges, leading to phantom missing packages or version conflicts.
Fix: Hash all pnpm-lock.yaml files across the repository. Use glob patterns or shell commands to aggregate checksums before generating the cache key.
7. Missing .npmrc Synchronization
Explanation: Local development environments and CI runners often operate with different pnpm configurations. Divergent store-dir, package-import-method, or strict-peer-dependencies settings cause resolution mismatches that only surface in production pipelines.
Fix: Commit a root .npmrc file containing all critical pnpm configurations. Ensure CI and local environments inherit identical settings. Validate configuration parity in the pipeline's first step.
Production Bundle
Action Checklist
- Define explicit store path via workflow environment variable
- Configure
pnpm config set store-dirbefore any installation step - Generate cache key by hashing all workspace
pnpm-lock.yamlfiles - Implement
restore-keysfallback prefix for incremental updates - Append
--frozen-lockfileto all installation commands - Use
--sortflag for workspace build execution - Decouple dependency resolution into a dedicated job
- Verify resolved store path in pipeline logs before proceeding
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Small monorepo (<200 deps) | Single job with explicit store cache | Simpler pipeline, cache persistence risk is low | Minimal compute savings |
| Medium monorepo (200-600 deps) | Split install/build jobs + --sort |
Guarantees cache persistence, prevents topological failures | 40-60% reduction in CI minutes |
| Large monorepo (>600 deps) | Dedicated install job + workspace hash + concurrency limits | Maximizes cache hit rate, isolates flaky builds from state management | 70-85% reduction in CI minutes |
| Self-hosted runners | Commit .npmrc with store-dir + persistent volume |
Eliminates path drift across heterogeneous machines | Reduces runner provisioning overhead |
Configuration Template
name: Monorepo CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
CI_PNPM_STORE: ${{ github.workspace }}/.pnpm-store
NODE_VERSION: 22
PNPM_VERSION: 9
jobs:
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 "${CI_PNPM_STORE}"
- name: Generate dependency hash
id: dep-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: ${{ env.CI_PNPM_STORE }}
key: pnpm-store-${{ runner.os }}-${{ steps.dep-hash.outputs.lock-hash }}
restore-keys: |
pnpm-store-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
compile-and-validate:
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 "${CI_PNPM_STORE}"
- name: Generate dependency hash
id: dep-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: ${{ env.CI_PNPM_STORE }}
key: pnpm-store-${{ runner.os }}-${{ steps.dep-hash.outputs.lock-hash }}
restore-keys: |
pnpm-store-${{ runner.os }}-
- name: Compile workspaces
run: pnpm run -r --sort build
- name: Run tests
run: pnpm run -r --sort test
Quick Start Guide
- Add environment variable: Define
CI_PNPM_STOREat the workflow level pointing to a deterministic path. - Configure store routing: Insert
pnpm config set store-dir "${CI_PNPM_STORE}"as the first pnpm command in every job. - Replace default caching: Remove
cache: 'pnpm'fromsetup-node. Addactions/cachetargeting the store path with a multi-lockfile hash key. - Enforce execution order: Append
--sortto allpnpm run -rcommands to respect workspace dependencies. - Validate pipeline: Trigger a test run. Verify that the first execution downloads packages, and subsequent runs report
reusedcounts matching the total dependency count.
