Build1 publisher3 min readPublished
A global pnpm setting in bol's container image sent the store outside the directory GitLab archived
bol has run its Backstage platform for more than five years and switched to pnpm so coding agents could work in parallel worktrees. Then came a store path two systems disagreed on and 1.42 GB of cached node_modules that saved nothing.
The Engineer · Build desk

What happened
- bol, which has run its internal developer platform on Backstage for more than five years, moved from Yarn 4 to pnpm, pinning pnpm 12.3.0 and committing pnpm-lock.yaml.
- Once caching worked, four jobs were each restoring a 1.42 GB archive of roughly 601,000 files that held the pnpm store and every node_modules tree.
- Removing node_modules from the cache paths cut the archive to roughly 400 MB and 244,000 files, which bol estimates saves about six minutes per pipeline.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint A warm pnpm store does not remove the dependency layout rebuild or the native compiles, so the minutes-long part of the install stays on the clock however good the cache hit rate looks.
- decision Anyone running pnpm on containerised runners now has to decide the store location inside the job and print it, because an image-level default outranks the CI cache path without saying so.
- cost A retry policy that cannot distinguish a dropped connection from a lockfile mismatch turns one deterministic error into six billed runner runs.
Two places set the store path, and nothing forced them to agree. bol's container image carried a global pnpm configuration pointing the store at /builds/.pnpm-store, while GitLab tried to archive .pnpm-store inside the project checkout [5]. pnpm wrote outside the archived directory, the archiver matched no files, and every job after it started cold [6]. The fix is two lines of CI config: set npm_config_store_dir to '.pnpm-store', then run `pnpm config set store-dir "$CI_PROJECT_DIR/.pnpm-store"` and `pnpm store path` in before_script so the job log shows the effective value [7].
Downloading was the fast part. 4,226 packages came down in about 18 seconds, and the native node-gyp builds added several minutes on top [4].
Once the store cached correctly, the archive became the problem. It held the pnpm store plus every node_modules tree: 1.42 GB across roughly 601,000 files, restored by four jobs [8]. That averages about 2.4 KB a file [17], and saving, transferring and extracting the archive turned into a substantial part of the pipeline [22]. The installs rebuilt the dependency layout and the native modules anyway, so the cached trees were not buying the time bol expected [9]. Removing node_modules, plugins/*/node_modules and packages/*/node_modules from the cache paths left roughly 400 MB and 244,000 files, about six minutes saved per pipeline by bol's estimate [10]. That is around 1.0 GB and 357,000 files taken out [18], and about 90 seconds per job across the four [19]. The stated 72% size cut checks out against the two archive figures [20].
pnpm's own CI documentation warns that caching its store is not guaranteed to make installation faster, and says the right policy depends on the runner, the network and the workload [12]. So the 72% is a measurement of bol's pipeline. It transfers if your archive is also mostly node_modules and your install recreates the layout and recompiles native modules regardless. On a runner that keeps a warm checkout between jobs, or a tree with no node-gyp in it, the phase worth measuring is a different one. bol labels its figures approximate observations taken during the migration [10], and it also switched installs to --frozen-lockfile --prefer-offline and picked faster cache compression [15].
Bogdan Nechyporenko, a developer at bol, ties the choice to the worktree workflow: "That workflow also made our package manager part of the architecture," he wrote [11]. The requirement he states is that independent worktrees move in parallel while package content is reused safely from one shared store [23].
bol also stopped retrying script failures automatically [13]. A dropped connection may deserve a retry, Nechyporenko wrote; a `--frozen-lockfile` mismatch will fail six times for exactly the same reason, and bill you for six runners on the way [14]. He describes local development as the hardest surprise of the migration, with a worktree bootstrap script running to about 350 lines [16].
What to watch
- The published account breaks off at local development and a roughly 350-line worktree bootstrap; the rest of those changes would show whether pnpm's layout holds up across shared worktrees.
- Whether bol keeps caching the pnpm store at all, given pnpm's documentation says the speed-up depends on the runner and the network.
- Whether the roughly 400 MB archive holds once --prefer-offline and the faster compression settle in, or whether the node-gyp builds become the remaining cost.