A pnpm monorepo built clean, passed a smoke test inside the builder stage, and then died on boot with Cannot find module '@repo/lib'. Nothing in the pipeline flagged it. The image had even gotten smaller. Between pnpm 11.19.0 and 11.21.x, pnpm deploy wrote workspace dependencies into the output directory as link: symlinks pointing back at the monorepo instead of injected copies, and the builder stage happily resolves those links because the source tree is still sitting right there. The COPY into the final stage is where the link goes dangling.

PR #13755 fixed it on 10 August 2026, shipping in 11.22.0 on 15 August. The two workarounds that circulated during the window, forceLegacyDeploy: true and injectWorkspacePackages: true, both do nothing, and the reason is worth understanding because one of them is a repo-wide change you will regret keeping.

pnpm versionWorkspace dep in out/injectWorkspacePackages neededPortable after COPY
11.18.0 and earlierfile: copyyes, or --legacyyes
11.19.0 to 11.21.xlink: to sourceignored, guard winsno
11.22.0 and laterfile: copyyes, or --legacyyes
12.2.0 and laterfile: in deploy lockfilenoyes

What changed in pnpm 11.19.0

PR #13112 merged on 30 July 2026 to close issue #10433, and it added a guard that preserves an existing link: entry for any workspace dependency the current operation does not explicitly target. The bug it fixed was real: pnpm update [email protected] --recursive had been flipping untouched dependencies from link:../pkg-d to a peer-suffixed file:packages/pkg-d(...), and under nodeLinker: hoisted that copied stale snapshots into node_modules. Preserving the link was the right call for that path.

The guard fired unconditionally. pnpm deploy deliberately disables dedupeInjectedDeps so its output stands alone, which put every deploy on a code path where a stale link: from the shared lockfile overwrote the freshly resolved file: entry. The fix in #13755 gates the guard on dedupeInjectedDeps being enabled, in both the TypeScript resolver (deps-resolver/src/index.ts, behind a new preserveDedupedWorkspaceLinks flag) and the Rust path (install_with_fresh_lockfile.rs). Two implementations, one condition, which is a reminder of how much surface the Rust rewrite added. If you are still planning that jump, the pnpm 12 upgrade traps worth clearing before CI breaks cover the rest of that surface.

Why did the Docker build stay green?

Every gate you already run reports success on a broken deploy tree.

pnpm install --frozen-lockfile passes because the lockfile is accurate. The wrong resolution lives in the deploy directory, which no lockfile check inspects. A smoke test in the builder stage passes because ../../../packages/lib still resolves at that point in the Dockerfile. And the image gets smaller, so a size-regression gate reads the breakage as a win: the reporter in issue #13754 measured a minimal repro dropping from 7 files to 5.

This is the same shape as the native-module failure after the npm 12 rewrite, where a missing build/Release directory surfaces as an identical Cannot find module line from an entirely different cause. Node's resolver gives you one error string for a whole family of packaging mistakes, and the only way through is to stop reading the message and start reading the tree.

Which of the three broken trees do you have?

Three distinct situations produce that error line from a pnpm deploy directory, and they take different fixes.

The 11.19 to 11.21 guard regression breaks every workspace dependency. All of them become a link: escaping the deploy root, uniformly, which at least makes it easy to spot.

Issue #13618 is the --legacy variant, and it breaks only workspace deps that declare peerDependencies. Legacy deploy materializes those packages by rewriting them to peer-suffixed file: protocols, and the guard blocked precisely that rewrite. A partial failure scattered across a monorepo reads as random flakiness until you line the broken packages up against their peerDependencies blocks and see the pattern.

The third case is a plain configuration error on pre-11.19 releases: no inject-workspace-packages, no --legacy. That one fails loudly with ERR_PNPM_DEPLOY_NONINJECTED_WORKSPACE and ships nothing.

The separating test is one readlink:

readlink out/node_modules/@repo/lib

A broken tree answers with ../../../packages/lib. A correct one points inside out/node_modules/.pnpm/file+packages+lib.... If the first result reproduces on 11.22.0 or later, the regression is not your problem and you are looking at a configuration error.

pnpm deploy --filter=app outdedupeInjectedDeps disabled for deployIs the PR 13112 guard gated on dedupeInjectedDeps?Stale link:../packages/libkept from shared lockfileFresh file:packages/libinjected copydocker COPY out /appdocker COPY out /appTarget outside imageCannot find module @repo/libSelf-contained treeruns in final stage"11.19 to 11.21: no gate""11.22.0 and later: gated"

Why forceLegacyDeploy did nothing

Both circulating workarounds are inert on 11.19 through 11.21 for the same mechanical reason: the guard runs before either setting has any bearing on the outcome. forceLegacyDeploy: true moves you onto the legacy path, where #13618 is waiting. injectWorkspacePackages: true asks for the injected copies that the guard is already overwriting.

The second one deserves a stronger warning than "it doesn't work." Setting injectWorkspacePackages: true across the repo changes how every install in the monorepo materializes workspace dependencies, for every developer, to work around a bug in one command. And since 12.2.0 removed the requirement for deploy entirely, you would be carrying a permanent global behavior change as a fossil of a three-week regression. Fix the version. Leave the workspace config alone.

What 12.2.0 changes about the deploy lockfile

pnpm 12.2.0, released 1 September 2026, synthesizes a dedicated deploy lockfile in which the workspace link becomes a file: dependency. That drops the inject-workspace-packages requirement, which the docs at pnpm.io/cli/deploy still describe as mandatory. Any assistant trained on that page will hand you a setting you no longer need and a diagnosis that stopped being true on 1 September, so check the release notes before you take upgrade advice on this command from a model.

The consequence nobody puts in the release notes: your deploy lockfile is now a separate artifact from the one you committed. An SBOM generated at the workspace root no longer describes the tree you shipped byte for byte, because the resolution protocols differ. If you gate admission on scan results, point Syft at the deploy directory rather than the repo root and regenerate. Same argument applies to whatever you attest: an image signature that covers the wrong inventory is worse than no signature, because it makes a stale bill of materials look verified.

Run this before your next image build

Five steps, in order, each tied to something above.

  1. Print pnpm --version in the build that produced the artifact. Anything from 11.19.0 to 11.21.x means stop and move to 11.22.0 or later, or to 12.x. This is the one check that resolves most cases in ten seconds.
  2. Add the escaping-symlink check as a build step against the deploy directory, and fail the build on a single hit. A symlink whose resolved target sits outside the deploy root makes the tree non-portable by definition, so the threshold is one.
   find out -type l | while read -r l; do
     t=$(readlink -f "$l") || continue
     case "$t" in "$PWD/out"/*) ;; *) echo "ESCAPES: $l -> $t" ;; esac
   done

Run it before the COPY, in the builder stage, and treat any output as a failed build. A smoke test at that same point cannot see this class of bug, because the symlink still resolves.

  1. On 12.2.0 or later, delete injectWorkspacePackages and forceLegacyDeploy from pnpm-workspace.yaml if you added them chasing this, then re-run step 2 to confirm the tree is still self-contained.
  2. If only some packages break, grep those packages for peerDependencies. A clean correlation is the #13618 signature and points at the legacy deploy path rather than the guard.
  3. Pin pnpm through the packageManager field in package.json with Corepack. A Dockerfile that installs pnpm@latest picked up 11.19.0 the day it shipped, and the version that produced any given artifact should be readable from the build log alone.

The counterargument to step 5 is that pinning delays fixes, and this incident is a fair example: pinned repos stayed on 11.18.0 and never saw the bug, while pinned repos that had already moved to 11.19.0 stayed broken for three weeks after the fix landed. Pinning buys you attribution, not immunity. It is worth the trade only if something in your process actually moves the pin, which means a scheduled dependency bump with a human reading release notes. A pin nobody advances is technical debt with a version number on it.