Skip to content
Fran Gonzalez
← Back to blog
·Clanker·5 min read

How pnpm deploy --legacy left dangling workspace symlinks in our Docker image

Starting with pnpm 11.19 in our build, `pnpm deploy --legacy` left workspace dependencies linked to source directories that the runtime image did not contain.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

A routine dependency-bump MR broke our API service’s staging deploy with ERR_MODULE_NOT_FOUND for a workspace:* package. The application and Dockerfile were unchanged. The rebuilt image used a newer pnpm release, and its pnpm deploy --legacy output contained a workspace symlink that pointed outside the deployed directory.

Note

I reproduced the deploy behavior with pnpm 11.17.0 through 11.21.0 against the same repository and lockfile on 2026-08-10.

The Problem

The deploy failed during the database-migration step, which runs inside the service container:

Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@scope/shared'
  imported from /workspace/src/env.ts

The image follows pnpm’s documented monorepo Docker pattern. A dependency stage installs everything, pnpm deploy prepares one app in /out, and the runtime stage copies only that directory:

# apps/api/Dockerfile
RUN pnpm --config.allow-unused-patches=true --filter @scope/api deploy --legacy --prod /out

FROM node:26.7.0-alpine3.23 AS runtime
WORKDIR /workspace
COPY --from=deps /out /workspace
CMD ["node", "--import", "tsx", "src/server.ts"]

@scope/api depends on a sibling workspace package:

// apps/api/package.json
{
  "dependencies": {
    "@scope/shared": "workspace:*",
  },
}

At runtime, /workspace/node_modules/@scope/shared was a dangling symlink. Node resolved the import, followed the link, found nothing, and aborted before the migration could run.

What Changed

The Dockerfile’s deploy --legacy line is unchanged since the service was created. What changed was the pnpm version it ran under. I reproduced the deploy against several pnpm releases against the same lockfile:

pnpmnode_modules/@scope/shared resolves toSelf-contained?
11.17.0 / 11.18.0../.pnpm/@scope+shared@file+packages+shared/node_modules/@scope/sharedyes: real files cloned into .pnpm
11.19.0 / 11.20.0 / 11.21.0../../../../.../packages/shared (the source tree, outside the bundle)no: external symlink, nothing cloned

Through pnpm 11.18, deploy --legacy placed the workspace package inside the bundle’s .pnpm directory and kept the top-level link within /out. In my pnpm 11.19 through 11.21 reproductions, the link pointed back to the monorepo source instead.

Docker’s COPY --from=deps /out /workspace preserved that symlink. The runtime stage did not contain the source tree, so the link pointed at a missing /workspace/packages/shared. I confirmed the Docker behavior with a minimal two-stage image:

FROM alpine:3.20 AS deps
WORKDIR /workspace
RUN mkdir -p packages/shared/src /out/node_modules/@scope
RUN echo "export {}" > packages/shared/src/index.ts
# the external source link observed in this build with pnpm >= 11.19
RUN ln -s ../../../workspace/packages/shared /out/node_modules/@scope/shared

FROM alpine:3.20
WORKDIR /workspace
COPY --from=deps /out /workspace
RUN ls -la node_modules/@scope/shared \
 && (cat node_modules/@scope/shared/src/index.ts && echo OK || echo DANGLING)
lrwxrwxrwx  shared -> ../../../workspace/packages/shared
DANGLING

Why it surfaced on an unrelated MR

The regression landed on main via a Renovate pnpm-node toolchain bump (11.18.0 → 11.19.0). But the service’s build → publish → staging deploy chain only auto-runs when its declared inputs change, and none of the intervening Renovate MRs touched them. The MR that finally rebuilt and redeployed the image was a security one, chore/security-audit-gate. It edited ci/projects/api.yml, which counts as a build input. That fresh build used pnpm 11.20, produced the broken /out, and the deploy exercised it for the first time. The defect had been latent for several days.

The observed boundary

The version boundary was repeatable with this repository and lockfile. pnpm 11.17 and 11.18 produced a self-contained deployment directory. pnpm 11.19 through 11.21 left the workspace package linked to its source directory outside /out.

Discussion #6362 reports similar legacy-deploy symlinks under an earlier workspace configuration. My results describe the versions and repository topology I tested.

The Fix

The pnpm deploy docs state the intended modern path plainly:

By default, the deploy command only works with workspaces that have the inject-workspace-packages setting set to true. If you want to use deploy without “injected dependencies”, use the --legacy flag or set force-legacy-deploy to true.

Why injection fixes it

A normal workspace dependency can be a live symlink to its source directory:

apps/api/node_modules/@scope/shared  ->  ../../packages/shared

That link works while the full workspace is present. A standalone deployment needs the workspace package inside the deployed dependency tree.

With inject-workspace-packages=true, pnpm materializes the workspace package in the virtual store used for deployment and links the package alias there:

apps/api/node_modules/@scope/shared  ->  ../.pnpm/@scope+shared@file+packages+shared/.../shared

The modern deploy path requires this setting because it builds a portable dependency tree containing the selected app’s workspace dependencies.

The change

I enabled injection for the deploy command and removed --legacy. Scoping the setting to this command avoided changing dependency layout for the other applications in the monorepo.

# apps/api/Dockerfile
RUN pnpm --config.allow-unused-patches=true --config.inject-workspace-packages=true --filter @scope/api deploy --prod /out

With injection on, the runtime symlink resolves inside the image. I confirmed it by building the image and running node --import tsx -e "import('@scope/shared')" in the runtime stage. The import completed without ERR_MODULE_NOT_FOUND.

pnpm 11.21.0 still produced the external symlink under --legacy in this repository. The configuration change, rather than another version bump, fixed the image.

What I’d Do Differently

I would add this smoke check to the runtime stage:

RUN node --import tsx -e "import('@scope/shared')"

Starting the image in CI and checking /livez would provide a broader test. Either check would have failed when the pnpm bump first rebuilt the image.

References

This post was written with AI assistance.