Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

Fixing Broken Symlinks in pnpm node_modules

A build that worked on your laptop dies in Docker with ENOENT on a path deep inside node_modules/.pnpm, or Node throws "Cannot find module" for a package you can clearly see in package.json. The cause is almost always a broken symlink: pnpm's node_modules is a tree of links into a virtual store, and copying, hoisting, or platform mismatches snap those links. This page covers the exact error strings, why the links break, and how to repair them with node-linker settings, --force, and Docker patterns that survive multi-stage copies.

Symptoms

Error: ENOENT: no such file or directory, stat
  '/app/node_modules/.pnpm/react@18.3.1/node_modules/react/index.js'
Error: Cannot find module '@repo/ui'
Require stack:
- /app/apps/web/dist/index.js
 ERR_PNPM_LINKING_FAILED  Failed to create bin at ... EEXIST
 ERR_PNPM_MODULES_BREAKING_CHANGE  The node_modules was created with a different node-linker
# In a Docker build after COPY:
node:internal/modules/cjs/loader: ENOENT, broken symbolic link
  node_modules/.bin/tsc -> ../typescript/bin/tsc

The unifying signature: the path exists in listings but resolves to nothing, because a symlink points at a target that was never copied or no longer exists.

Root cause

By default pnpm uses an isolated layout. Real package files live once in node_modules/.pnpm/<name>@<version>/node_modules/<name>, and every place that depends on a package gets a symlink into that virtual store. This is the design behind Workspace Symlinks vs Hard Links: isolation prevents phantom dependencies, but it means the dependency tree is a web of symlinks rather than copied files. Links break when:

  • A COPY node_modules in a Dockerfile copies the symlinks but not their .pnpm targets (or copies them in the wrong order), leaving dangling links.
  • node_modules is copied or rsynced between machines or build stages without preserving symlinks.
  • The node-linker setting differs between the install that created the tree and the tool now reading it (ERR_PNPM_MODULES_BREAKING_CHANGE).
  • Hoisting expectations are wrong: a tool reaches for a phantom dependency that isolated linking deliberately does not expose at the top level.
  • Windows blocks symlink creation without the right privileges, so pnpm falls back or fails.
Choosing a node-linker to fix broken links A decision flow from a broken-symlink symptom to either keeping isolated linking or switching to hoisted linking. broken symlink ENOENT / not found copying node_modules? Docker / rsync keep isolated linker re-run pnpm install in image node-linker=hoisted flat tree, no link to copy no yes & copies break
If you must copy node_modules across stages, either re-install in place or switch to a hoisted (flat) linker.

Resolution

1. Confirm the link is actually dangling

Find symlinks whose target does not exist before changing any config:

# List broken symlinks under node_modules
find node_modules -xtype l

# Inspect what a specific link points at
ls -l node_modules/@repo/ui
readlink node_modules/.bin/tsc

If find -xtype l prints paths, those targets are missing — that is the failure.

2. Rebuild the store from scratch

A force install discards the partial tree and re-creates every link against the current store. This fixes most local breakage and ERR_PNPM_LINKING_FAILED:

rm -rf node_modules
pnpm store prune
pnpm install --force

3. Resolve a node-linker mismatch

ERR_PNPM_MODULES_BREAKING_CHANGE means the tree was built with a different linker than the current config requests. Pick one mode and set it in .npmrc so every environment agrees:

# .npmrc — isolated (default): strict, symlinked virtual store
node-linker=isolated

# or hoisted: flat node_modules, no virtual-store symlinks
# node-linker=hoisted

After changing it, delete node_modules and reinstall so the tree matches.

4. Fix phantom-dependency "not found" errors

If a tool fails to find a package that you never declared (it relied on hoisting), the correct fix is to declare it as a real dependency. As an escape hatch when a third-party tool insists on a flat layout, hoist explicitly:

# Flatten everything to the top level (last resort; reintroduces phantom-dep risk)
pnpm install --shamefully-hoist

Prefer adding the missing package to dependencies over --shamefully-hoist, which undoes the isolation guarantees from Workspace Symlinks vs Hard Links.

5. Fix Docker multi-stage copies

Never COPY node_modules from a build stage and expect symlinks to resolve. Either copy the source plus lockfile and install inside the image, or copy the entire workspace (so the .pnpm store travels with its links). The reliable pattern installs in the image:

FROM node:20-slim AS deps
RUN corepack enable
WORKDIR /app
# Copy only manifests + lockfile for cacheable installs
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/ui/package.json ./packages/ui/
COPY apps/web/package.json ./apps/web/
RUN pnpm install --frozen-lockfile

FROM node:20-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm --filter web build

When you must carry node_modules between stages, copy /app as a whole so the .pnpm virtual store and the links into it stay together. Keep installs deterministic with --frozen-lockfile, consistent with Lockfile Management Strategies.

6. Windows symlink permissions

On Windows, symlink creation requires Developer Mode or the SeCreateSymbolicLinkPrivilege. Enable Developer Mode, or set node-linker=hoisted in .npmrc so pnpm produces a flat tree that needs no symlinks.

Validation

# No broken links should remain
find node_modules -xtype l

# The previously failing module resolves
node -e "require.resolve('@repo/ui'); console.log('ok')"

# Workspace integrity across all packages
pnpm list --recursive --depth=0

CI guardrails

  • Always pnpm install --frozen-lockfile in CI and Docker; never COPY a node_modules built on a different OS or linker.
  • Pin node-linker in a committed .npmrc so dev, CI, and Docker build identical trees.
  • Add a find node_modules -xtype l check to CI that fails if any dangling link appears.
  • Prefer installing inside the Docker image over copying node_modules across stages.
  • Declare every package you import as a real dependency rather than relying on hoisting; treat --shamefully-hoist as a temporary unblock, not a fix.

Frequently Asked Questions

Why does pnpm work locally but break in Docker with ENOENT? Because a Dockerfile COPY node_modules copies the symlinks but not the .pnpm virtual-store targets they point at, leaving dangling links. Run pnpm install --frozen-lockfile inside the image instead of copying node_modules, or copy the entire workspace directory so the store travels with its links.

What does ERR_PNPM_MODULES_BREAKING_CHANGE mean? The existing node_modules was created with a different node-linker than your current config requests (for example, isolated versus hoisted). Set node-linker explicitly in .npmrc, delete node_modules, and reinstall so the tree matches the configured linker.

Should I use --shamefully-hoist to fix "module not found"? Only as a last resort for third-party tools that demand a flat layout. The proper fix is to declare the missing package in dependencies. --shamefully-hoist flattens everything and reintroduces the phantom-dependency problems pnpm's isolated linking is designed to prevent.

How do I find broken symlinks before they fail at runtime? Run find node_modules -xtype l, which lists symbolic links whose target does not exist. An empty result means every link resolves; any output is a dangling link you should fix by reinstalling.

Related

Workspace Symlinks vs Hard Links