Skip to content
loke.dev
A sealed deployment crate tethered by a chain to a distant source workspace

Fix pnpm 11 deploy Shipping Workspace Source Symlinks

Detect pnpm 11.19 to 11.21 deploy artifacts that link back into a monorepo, pin the verified last-good release, and keep Docker images portable.

Published Updated 6 min read

pnpm 11.19.0 through 11.21.0 can finish a legacy deploy successfully while leaving a direct workspace dependency linked back to the source monorepo. The output works on the build machine, where that source path exists. Copy the same directory into a Docker image or artifact bundle and Node can fail at startup with ERR_MODULE_NOT_FOUND.

As of August 11, 2026, pnpm 11.21.0 is the current registry release. The upstream fix is merged but has not shipped in a stable release. The immediate safe path is to pin pnpm 11.18.0, rebuild the artifact, and add a link check before the Docker copy or artifact upload.

The short fix

  • Confirm that the deploy job is using pnpm 11.19.0, 11.20.0, or 11.21.0.
  • Temporarily pin the repository and CI job to pnpm 11.18.0, the last version reported and reproduced as working.
  • Delete and rebuild only the deploy output. Do not reuse an artifact produced by an affected version.
  • Check that direct workspace links resolve inside the deploy directory.
  • Copy the result away from the monorepo and run the real production entry point.

If your repository already pins pnpm with packageManager, change that existing pin and make sure CI honors it. The pnpm installation guide also documents exact-version installation for teams using the standalone installer.

{
  "packageManager": "pnpm@11.18.0"
}
pnpm --version
pnpm --filter @acme/server deploy --prod out
node check-deploy-links.mjs out

The package name and output path above are illustrative. Keep the filter and entry point used by your own deploy job.

Prove that the artifact is portable

The pnpm deploy contract is explicit: the target should contain an isolated node_modules directory and be portable enough to copy to a server without another install. A green deploy command is therefore not the useful test. The useful test is whether its links remain inside the artifact.

Save this checker in the repository and run it after pnpm deploy. It scans direct deployed dependencies while skipping pnpm's internal .pnpm virtual store. It fails on a broken link or a link that resolves outside the deploy root.

import { lstat, readdir, readlink, realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";

const root = resolve(process.argv[2] ?? "out");
let failures = 0;

async function walk(directory) {
  for (const entry of await readdir(directory, { withFileTypes: true })) {
    const path = resolve(directory, entry.name);
    if (path === resolve(root, "node_modules/.pnpm")) continue;

    const stats = await lstat(path);

    if (stats.isSymbolicLink()) {
      const link = await readlink(path);
      let target;

      try {
        target = await realpath(path);
      } catch {
        console.error(`Broken symlink: ${relative(root, path)} -> ${link}`);
        failures += 1;
        continue;
      }

      const fromRoot = relative(root, target);
      const escaped =
        fromRoot === ".." ||
        fromRoot.startsWith(`..${sep}`) ||
        isAbsolute(fromRoot);

      if (escaped) {
        console.error(
          `Escaping symlink: ${relative(root, path)} -> ${target}`,
        );
        failures += 1;
      }
      continue;
    }

    if (stats.isDirectory()) await walk(path);
  }
}

await walk(root);

if (failures > 0) process.exit(1);
console.log(`Portable symlink check passed: ${root}`);

I ran this exact script against the same minimal workspace deployed by both versions. It failed on 11.21.0 and passed on 11.18.0.

pnpm 11.21.0:
Escaping symlink: node_modules/@fixture/shared -> /tmp/repro/packages/shared
exit 1

pnpm 11.18.0:
Portable symlink check passed: /tmp/deploy-11-18
exit 0

The paths are shortened from the local fixture, but the commands and pass/fail result are reproduced, not illustrative.

Run a copied-artifact smoke test too

The link guard catches this regression before packaging. A copied-artifact smoke test covers a wider class of mistakes, including files excluded by package.json files, .npmignore, or .gitignore rules.

pnpm --filter @acme/server deploy --prod out

mkdir -p .artifact-smoke/app
cp -R out/. .artifact-smoke/app/
cd .artifact-smoke/app
node index.js

Run this in a disposable CI workspace. Replace node index.js with the same command your container starts. The separate nesting matters because it changes the relative location: a source-tree link that happened to work beside the monorepo becomes broken.

In the reproduced 11.21.0 artifact, the deploy command exited successfully. The copied directory then failed with ERR_MODULE_NOT_FOUND for the workspace package. The 11.18.0 artifact printed the fixture's expected output.

Why the build passes and the container fails

The original pnpm report #13618 describes a Cloud Run deployment where the build stayed green because the symlink target still existed on the CI runner. The container copied only the deploy directory, so the same link dangled and the startup probe failed.

An independent reproduction in #13754 showed the broader form of the bug with an ordinary workspace dependency. It reproduced on 11.19.0, 11.20.0, and 11.21.0. Both forceLegacyDeploy: true and injectWorkspacePackages: true still produced non-portable output.

That distinction matters during diagnosis. If installation or compilation fails, you have a different problem. This regression has a more deceptive shape: install succeeds, deploy succeeds, and the source-adjacent output may run. Failure begins after the artifact is moved without the rest of the monorepo.

Inspect one dependency by hand

If you need a fast confirmation before adding the checker, inspect a direct workspace dependency in the output.

readlink out/node_modules/@acme/shared
realpath out/node_modules/@acme/shared

On an affected deploy, realpath points into the source workspace instead of a path under out. On the verified 11.18.0 control, the direct link resolves into out/node_modules/.pnpm and remains valid after the directory is copied.

What changed upstream

The merged fix in pull request #13755 traces the regression to dependency resolution. A guard added for a different workspace-link case rewrote fresh file: entries back to stale link: entries. Legacy deploy needs the file: form so it can materialize workspace packages inside the output. The patch limits that guard to runs where dedupeInjectedDeps is active.

The pull request adds regression coverage for both pnpm's TypeScript CLI and its Rust implementation. That supports the diagnosis, but a merged commit is not the same as a released package. Do not point production at a pull-request build or an unpinned development artifact.

Do not treat configuration toggles as the fix

The affected workflow is already the legacy path. Adding --legacy again, setting forceLegacyDeploy: true, or enabling injectWorkspacePackages does not repair 11.19.0 through 11.21.0 in the independent reproduction.

Switching away from legacy deploy may be a valid migration for a repository that can meet the current deploy requirements. It is not a low-risk emergency edit. The current documentation says non-legacy deploy expects injectWorkspacePackages: true and creates a dedicated lockfile. Test that workflow as a separate change, with its own lockfile and runtime checks.

A safer Docker gate

Put the portability check in the build stage immediately after deploy, before the final image receives the files.

FROM node:24-slim AS build
WORKDIR /workspace
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @acme/server deploy --prod /out
RUN node check-deploy-links.mjs /out

FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /out ./
CMD ["node", "index.js"]

This Dockerfile is illustrative because workspace build steps and pnpm installation differ by project. The ordering is the important part: build the artifact, validate it, then copy it into the runtime stage.

Upgrade after the fix ships

Keep the workaround narrow. Once a stable pnpm release notes the fix from #13755:

  • Update the existing pnpm pin to that stable release.
  • Reinstall with the repository's normal frozen-lockfile policy.
  • Produce the deploy output from a clean workspace.
  • Run the link check and the copied-artifact smoke test.
  • Build the production container and run its real startup or health check.
  • Remove the temporary 11.18.0 pin only after those checks pass.

Keep the guard after upgrading. The cost is small, and it tests the property deployment actually depends on: the artifact can leave the monorepo without leaving its runtime dependencies behind.