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.
pnpm builds node_modules from symlinks into its content-addressed store, so a symlink breaks when the target it points at is gone or unreachable — the store was pruned, the project was moved without the store, or a tool copied node_modules across a boundary that did not preserve the links. The symptom is an ENOENT or a module-not-found error where the package clearly exists, because the link is dangling rather than the package missing.
The deeper cause is usually an operation that treated node_modules as a self-contained directory when under pnpm it is a web of links into an external store. Copying it between Docker build stages, moving a project folder without the store, or restoring a node_modules cache from a different machine all orphan the links, because the store they point at is not where the links expect it. The fix is almost always to recreate the links in place with a fresh install rather than to repair individual symlinks.
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.
Recreate the links with a fresh install rather than repairing them by hand:
# Rebuild node_modules from the store, recreating the symlinks
rm -rf node_modules
pnpm install --frozen-lockfile
# If the store itself is damaged, verify and repair it
pnpm store status
pnpm install --force
A frozen install reconstructs the exact tree the lockfile describes, with the symlinks pointing at the correct store locations. If the store is corrupted or was pruned, pnpm install --force re-fetches the missing content, and pnpm store status reports any integrity problems.
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.
Why the links break in Docker and CI
The symlink-and-store model interacts badly with any operation that moves node_modules without its store, which is exactly what a naive Docker build or CI cache does. Copying a host-built node_modules into a Docker image orphans every symlink, because the store the links point at is on the host, not in the image. Restoring a node_modules cache built on one runner onto another can do the same if the store path differs. The result is an image or a job that fails to resolve modules despite node_modules being present, which is confusing until you realize the directory is a set of links, not files.
The robust patterns keep the links and their targets together. In Docker, install inside the image with the store on a cached layer or a mounted volume, rather than copying a host-built tree — pnpm's fetch-and-install recreates the links in place, pointing at a store that exists in the same context. In CI, cache the pnpm store (keyed on the lockfile) and run a frozen install, which reconstructs node_modules from the restored store rather than restoring node_modules directly. The principle in both cases is that the links are relative to a store location, so any operation that separates the project from its store must be replaced by a fresh install that recreates the links where the store actually is.
Diagnosing and repairing a damaged store
Sometimes the symlinks are fine but the store they point at is damaged — a package's content was corrupted, or a partial prune removed content the lockfile still references. The symptom is similar (a module fails to resolve or load), but the fix is at the store level rather than the node_modules level. pnpm store status reports content that is missing or fails its integrity check, which tells you whether the store is the problem.
# Report store integrity problems
pnpm store status
# Re-fetch missing or corrupted content
pnpm install --force
# Prune orphaned content deliberately (not during an active project)
pnpm store prune
pnpm install --force re-fetches any content the store is missing, verifying it against the lockfile's integrity hashes, which repairs a store that was partially pruned or corrupted. Reserve pnpm store prune for deliberate cleanup, since it removes content no lockfile references — running it while a project depends on that content is how a store gets into the damaged state in the first place. Understanding the two layers — the symlinks in node_modules and the content in the store — is what lets you target the repair correctly: recreate the links for a link problem, re-fetch the content for a store problem.
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.
Why do I get ENOENT for a package that's clearly installed under pnpm?
The symlink in node_modules is dangling — it points at a store location that is gone or unreachable, usually because the project was moved or node_modules was copied without its store. Recreate the links with rm -rf node_modules && pnpm install --frozen-lockfile.
Why do pnpm symlinks break in my Docker build?
Copying a host-built node_modules into the image orphans the symlinks, because the store they point at is on the host, not in the image. Install inside the image (with the store on a cached layer or volume) rather than copying a host-built tree.
How do I fix a damaged pnpm store?
Run pnpm store status to find missing or corrupted content, then pnpm install --force to re-fetch it, verified against the lockfile's integrity hashes. Reserve pnpm store prune for deliberate cleanup, since pruning content a project needs is what damages the store.
Will npm install fix broken pnpm symlinks?
Use pnpm install, not npm install — the project is a pnpm workspace, and installing with a different manager would produce a conflicting layout. rm -rf node_modules && pnpm install --frozen-lockfile recreates the symlinks against the correct store.
Why not just recreate the individual broken symlink?
Because a broken link usually signals the store moved or was pruned, so other links are likely broken too, and hand-recreating them can point at the wrong store content. A fresh pnpm install rebuilds every link against the correct store deterministically, which is faster and safer than repairing links one at a time.
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.