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_modulesin a Dockerfile copies the symlinks but not their.pnpmtargets (or copies them in the wrong order), leaving dangling links. node_modulesis copied or rsynced between machines or build stages without preserving symlinks.- The
node-linkersetting 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.
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 /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-lockfilein CI and Docker; neverCOPYanode_modulesbuilt on a different OS or linker. - Pin
node-linkerin a committed.npmrcso dev, CI, and Docker build identical trees. - Add a
find node_modules -xtype lcheck to CI that fails if any dangling link appears. - Prefer installing inside the Docker image over copying
node_modulesacross stages. - Declare every package you import as a real dependency rather than relying on hoisting; treat
--shamefully-hoistas 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 — how pnpm's isolated linking and virtual store actually work.
- Cross-Package Dependency Management — declaring workspace dependencies so links resolve.
- Lockfile Management Strategies — deterministic installs that rebuild identical link trees.
- Migrating from Yarn 1 to pnpm Workspaces — moving from a hoisted layout to pnpm's symlinked store.