Skip to main content
Back to Blog
Tutorial
2026-07-13

Cache the pnpm Store for Tests in GitLab CI

Cache the pnpm store in GitLab CI with lockfile-derived keys, project-local paths, trusted writers, and pull-only test jobs that stay reproducible.

Cache the pnpm Store for Tests in GitLab CI

The cache archive restores successfully, yet pnpm install downloads every package again. In most cases the runner archived one store path while pnpm used another. GitLab only caches paths under the project checkout, and pnpm's default store location varies with its environment. The reliable fix is to choose a project-local store explicitly, hash the lockfile into the cache key, and keep installation in the job.

Caching pnpm's content-addressable store is an optimization for package retrieval. It is not a replacement for pnpm install --frozen-lockfile, not a way to share node_modules blindly, and not guaranteed to make every pipeline faster. Archive transfer and compression can outweigh registry downloads. This tutorial builds a reproducible configuration first, then shows how to decide if the cache earns its cost.

For the wider pipeline anatomy around runners, services, and test reports, use the GitLab CI test automation guide. To place dependency preparation behind merge controls and required jobs, see the GitLab CI quality gates guide.

Understand what pnpm's store contributes

pnpm keeps package contents in a global content-addressable store and constructs each project's dependency layout from that content. When a required package is already present, installation can reuse it instead of fetching the tarball from the registry. The project still needs its node_modules layout, lifecycle scripts, lockfile validation, and workspace links.

That separation is why caching the store is safer than caching node_modules. A restored store is an input to a fresh deterministic install. A restored module tree is already an installation product whose links and generated files may reflect another job, Node version, architecture, or script environment.

Directory or fileRoleCache recommendation
.pnpm-store/Downloaded content-addressed packagesCandidate for GitLab cache
node_modules/Project dependency layout and linksRecreate with pnpm install
pnpm-lock.yamlExact dependency graph and integrity dataTrack in source, use for key
pnpm metadata cacheRequest and metadata accelerationOptional, version-sensitive
Test reportsOutcome of a particular jobUpload as artifacts, not cache
Build outputGenerated product used downstreamUsually artifacts with explicit dependency

GitLab caches can be reused across pipelines. Artifacts pass declared results between jobs in a pipeline. A test report belongs to the run that created it, while a package store can accelerate many runs. Mixing those lifecycles leads to stale reports or needless dependency uploads.

Put the store below the checkout root

GitLab cache paths are relative to CI_PROJECT_DIR and cannot point outside the project. Configure pnpm before installation:

corepack enable
corepack prepare pnpm@9.15.0 --activate
pnpm config set store-dir .pnpm-store
pnpm store path
pnpm install --frozen-lockfile

The final pnpm store path is a diagnostic worth retaining during rollout. It should resolve inside the checkout. Use the pnpm major and version declared by the repository's packageManager field. A mismatched major can reject or reinterpret a lockfile, so “install latest pnpm” is not reproducible CI.

Setting PNPM_STORE_DIR or the pnpm_config_store_dir environment variable can also work, but one explicit mechanism is easier to diagnose. Avoid configuring a home-directory path and then listing .pnpm-store under cache.paths; those are different directories even if their names look similar.

Use a lockfile-derived cache key

GitLab's cache:key:files derives a key from file content. Point it at pnpm-lock.yaml so dependency graph changes create a new cache entry. Add a prefix for dimensions that change package compatibility, commonly pnpm major, Node major, runner architecture, or job family.

This complete job uses a project-local store, a lockfile key, and a frozen install:

stages:
  - test

variables:
  PNPM_VERSION: '9.15.0'
  PNPM_STORE_DIR: '.pnpm-store'

unit_tests:
  stage: test
  image: node:20-bookworm-slim
  before_script:
    - corepack enable
    - corepack prepare "pnpm@${PNPM_VERSION}" --activate
    - pnpm config set store-dir "${PNPM_STORE_DIR}"
    - pnpm store path
    - pnpm install --frozen-lockfile
  script:
    - pnpm test -- --run
  cache:
    key:
      files:
        - pnpm-lock.yaml
      prefix: "pnpm9-node20-${CI_RUNNER_EXECUTABLE_ARCH}"
    paths:
      - .pnpm-store/
    policy: pull-push
    when: on_success
  artifacts:
    when: always
    reports:
      junit: reports/junit.xml
    paths:
      - reports/
    expire_in: 7 days

The pnpm command after script must match the repository. Vitest accepts --run, while Jest and other runners differ. The cache configuration does not justify inventing a test flag.

The prefix is intentionally explicit. CI_RUNNER_EXECUTABLE_ARCH distinguishes runner architecture. The image fixes Node 20 and a Debian family. If multiple jobs use identical dependencies on identical runner classes, keep the prefix common so they can reuse one entry. If native packages behave differently by job image, split it.

Frozen installation remains the correctness gate

A warm store can contain more packages than the current project needs. That is normal. pnpm addresses content by integrity and links only what the lockfile selects. The required correctness command remains pnpm install --frozen-lockfile. It fails when manifest and lockfile disagree rather than editing the lockfile inside CI.

Do not switch to --offline simply because a cache exists. GitLab cache availability is not guaranteed, entries can be evicted, and a new package may be absent. Strict offline mode converts an optimization miss into a pipeline failure. --prefer-offline is an optional pnpm behavior when the team wants cached data favored while allowing network recovery, but benchmark it and understand metadata freshness before adopting it.

Install modeCache miss behaviorSuitable default in CI
--frozen-lockfileDownloads missing content, never rewrites lockfileYes
--offlineFails if required package metadata or content is absentOnly sealed, prefilled environments
--prefer-offlinePrefers local data but can contact registryOptional after measurement
No frozen lockfileMay update dependency resolutionAvoid for verification jobs

A test pipeline must pass from an empty store. Periodically run a no-cache canary or clear the key prefix in a branch to verify this invariant. Otherwise a package available only from historical cache can hide a broken registry, missing authentication, or unpublished dependency.

Stop parallel jobs from uploading the same archive

With policy: pull-push, every test job restores and uploads. A large matrix can spend substantial time compressing nearly identical stores, and concurrent writers create redundant work. Use one preparation job as the trusted writer and configure fan-out test jobs with policy: pull.

.pnpm_cache: &pnpm_cache
  key:
    files:
      - pnpm-lock.yaml
    prefix: "pnpm9-node20-${CI_RUNNER_EXECUTABLE_ARCH}"
  paths:
    - .pnpm-store/

warm_pnpm_store:
  stage: .pre
  image: node:20-bookworm-slim
  cache:
    <<: *pnpm_cache
    policy: pull-push
  script:
    - corepack enable
    - corepack prepare pnpm@9.15.0 --activate
    - pnpm config set store-dir .pnpm-store
    - pnpm install --frozen-lockfile
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

browser_tests:
  stage: test
  image: node:20-bookworm-slim
  cache:
    <<: *pnpm_cache
    policy: pull
  before_script:
    - corepack enable
    - corepack prepare pnpm@9.15.0 --activate
    - pnpm config set store-dir .pnpm-store
    - pnpm install --frozen-lockfile
  script:
    - pnpm test:e2e

This example warms only on the default branch. Whether feature branches can restore that entry depends on GitLab's cache scope and protected-branch settings. Do not weaken protected and unprotected separation merely to improve hit rate. A low-trust merge request must not poison an executable dependency cache later used with deployment credentials.

The warm job is useful only if downstream jobs can access distributed cache and if its delay does not exceed the saved time. On GitLab.com instance runners, distributed cache is available. Self-managed fleets need shared storage or consistent runners. Without it, the warm job may upload to one runner while consumers execute elsewhere with no shared backend.

Choose cache scope around trust and reuse

A lockfile-only key allows branches with identical dependencies to reuse content. A branch-specific key isolates writers but guarantees the first pipeline on every branch starts cold. A hybrid uses the lockfile content plus protected-scope behavior supplied by GitLab.

Key strategyHit rateIsolationTypical use
CI_COMMIT_REF_SLUGMedium after first branch runStrong branch separationLong-lived untrusted branches
Lockfile contentHigh across matching branchesRelies on GitLab scope controlsTrusted project pipelines
Job name plus lockfileLower across job typesAvoids incompatible consumersDifferent images or native toolchains
Commit SHAAlmost always coldMaximum isolationPoor choice for dependency acceleration
Static keyHigh until pollutedWeak invalidationAvoid unless contents are intentionally cumulative

GitLab separates protected and non-protected caches by default. Keep that security boundary unless all cache writers are trusted equivalently. A package store contains executable JavaScript and lifecycle scripts may run during installation. Treat restored bytes as untrusted inputs even when their integrity is checked through the lockfile.

Account for native addons and install scripts

The content-addressable store primarily holds package content, but dependency installation can involve native addons, platform-specific optional packages, and lifecycle-generated artifacts. The safest key includes material compatibility dimensions and installation runs inside the target job image.

If the monorepo tests Node 20 and Node 22, use a Node-major prefix even when most JavaScript packages are identical. This sacrifices some reuse for easier failure analysis. Teams can later prove that a shared store is safe for their dependency graph and consolidate. Correctness precedes maximal cache density.

Never cache secrets written by registry authentication. Configure scoped registry tokens through masked CI variables and ensure .npmrc with interpolated credentials is not inside a cache path or artifact. Prefer short-lived credentials where the registry supports them.

Monorepos need one graph key, not package guesses

In a pnpm workspace, the root pnpm-lock.yaml usually describes the dependency graph for every package. Key the store from that file and run the install at the workspace root. Filtering tests afterward with pnpm --filter ... test is fine, but installing independently in package subdirectories can miss workspace links and produce inconsistent stores.

GitLab supports at most a bounded number of cache entries per job, so resist separate stores for dozens of packages. The shared store already deduplicates package content. Split caches only for a compatibility or trust reason, not to mirror the folder tree.

If multiple lockfiles genuinely exist for independent projects, define separate jobs or cache entries with clear prefixes. Remember that cache:key:files accepts only a limited number of files. A generated combined checksum is possible, but generating it before GitLab restores the cache is awkward because restoration occurs before job scripts. Repository structure is often the cleaner fix.

Measure archive economics

The pnpm documentation explicitly notes that caching the store is optional and not guaranteed to speed installation. Measure restore, install, and upload separately. A hit that saves thirty seconds during install but adds forty seconds of cache transfer is a regression.

Capture these values from job logs over representative pipelines:

  1. Cache download and extraction time.
  2. pnpm install duration and registry requests.
  3. Cache creation and upload time on writer jobs.
  4. Archive size and eviction frequency.
  5. Total test job duration at p50 and a tail percentile.

Avoid fabricated universal savings. Registry proximity, dependency count, runner disk, compression, and network paths vary too much. The best configuration for GitLab.com shared runners may be wrong for an on-premises runner beside an internal registry.

Run pnpm store prune only as an intentional maintenance choice. Frequent pruning removes versions useful when switching branches and adds work before archive upload. Immutable lockfile keys naturally age out old entries through cache retention policies; they do not require pruning on every job.

Diagnose the common failure signatures

When the cache appears ineffective, compare the logged pnpm store path with cache.paths first. Then inspect exact cache keys and runner availability.

SymptomInvestigationRepair
“Successfully extracted cache” but full downloads followStore path mismatchSet project-local store-dir before install
Cache never found on another runnerNo distributed cache backendConfigure shared cache or accept cold jobs
New dependency is mysteriously unavailableStrict offline installAllow network recovery with frozen lockfile
Test matrix uploads for minutesEvery job uses pull-pushAssign one writer, consumers use pull
Native addon fails after Node upgradeCompatibility missing from keyAdd Node major or image identity to prefix
Feature branch cannot see default cacheProtected-scope or key separationPreserve trust boundary, warm appropriate scope

Do not “fix” a miss by replacing the key with a constant. That trades visible network cost for invisible staleness and cache contention. A predictable miss on a changed lockfile is correct behavior.

Rehearse cache loss and registry failure separately

Two resilience drills answer different questions. In the first, change the cache prefix or disable cache for one pipeline while the registry remains available. The install should recreate the store and all tests should run. This proves the cache is optional. In the second, keep a known complete cache but make the registry unavailable in an isolated environment. The outcome depends on pnpm metadata, lifecycle scripts, and whether every required package is present. Do not promise offline resilience until that drill succeeds intentionally.

A warm content store does not guarantee that installation needs no network. Metadata checks, git dependencies, remote lifecycle downloads, package-manager preparation, and application test fixtures may still call external systems. Capture network destinations during the drill and decide which are legitimate. If fully hermetic installation is a requirement, build a supported offline process or internal registry mirror rather than depending on yesterday's opportunistic GitLab archive.

Registry authentication also needs a cold-path test. A cache hit can conceal an expired token for weeks, then a lockfile change causes an apparently unrelated pipeline failure. Schedule a small job that installs a private fixture package into an empty temporary store using the same CI credentials. Keep the fixture harmless and avoid publishing secrets in command output.

Use failure classification in job logs:

Failure pointOwnership clueUseful next check
Corepack cannot activate pnpmRuntime image or outbound package-manager accessPinning and bundled package-manager policy
Cache restore missesKey, scope, eviction, or runner backendExact resolved key and protected status
Integrity verification failsRegistry content or corrupted inputLockfile integrity and trusted source
Private package returns 401CI credential expired or scope changedMasked variable availability and registry path
Lifecycle script reaches internetDependency is not hermeticReplace, mirror, or explicitly permit destination

These drills keep performance state from becoming hidden availability state. Teams should be able to delete every pnpm cache entry and still obtain a correct, if slower, pipeline.

Frequently Asked Questions

Why cache the pnpm store instead of node_modules?

The store supplies verified package content to a fresh install. node_modules contains a project-specific linked layout and generated artifacts that can be coupled to another job's Node version, platform, or scripts.

Must the store be inside CI_PROJECT_DIR?

For GitLab's project cache paths, yes. Set store-dir to a relative directory such as .pnpm-store and print pnpm store path to verify that pnpm and GitLab agree.

Should every parallel test job upload the cache?

Usually not. Let a trusted preparation or default-branch job use pull-push, and make fan-out consumers pull. This avoids repeated compression and competing writes.

Is --offline safer once the cache is warm?

It is stricter, not automatically safer. Cache availability is not guaranteed, so offline mode can fail valid builds on eviction or a cold runner. Frozen lockfile installation already protects dependency resolution while allowing missing content to download.

When should I remove the pnpm store cache?

Remove it when measured restore plus upload cost meets or exceeds install savings, when a nearby registry is faster, or when cache sharing cannot be secured for the pipeline's trust model.

Cache the pnpm Store for Tests in GitLab CI | QASkills.sh