Workspace Symlinks vs Hard Links
Modern package managers build node_modules out of two filesystem primitives that are easy to confuse and behave very differently: symlinks and hard links. pnpm uses both at once — symlinks to wire workspace packages and their dependencies into each project's node_modules, and hard links to share immutable package files from a single content-addressable store on disk. Understanding which primitive does what is the difference between a fast, disk-efficient install and a debugging session over a phantom dependency, a broken link, or a duplicated package instance in a production bundle. This page covers the resolution mechanics, the on-disk layout, cross-platform fallbacks, and the security implications of each.
These link mechanics sit underneath your whole Monorepo Architecture & Orchestration setup: they determine how one package "sees" another, which is exactly what Cross-Package Dependency Management governs at the manifest level, and how the install is shaped is configured through your Workspace Configuration Deep Dive settings. When a symlink in node_modules points nowhere, the fix path is its own topic: Fixing Broken Symlinks in pnpm node_modules.
The problem statement
A symlink is a logical pointer to another path; a hard link is a second name for the same inode (the same physical file). pnpm uses each for what it is good at: symlinks give every project a node_modules tree that resolves to canonical package paths without copying, and hard links let many projects share one on-disk copy of a package's files with zero duplication. Confuse the two — use a hard link where a symlink belongs, or let a symlink dangle — and resolution breaks in ways that are invisible until a build fails.
Core resolution mechanism: symlinks
Symlinks maintain strict dependency isolation while preserving logical workspace boundaries. Each project's node_modules contains symlinks to the exact package versions that project declares, so a package can only import what it actually depends on — pnpm's defense against phantom dependencies. The symlinks also enable live reload: editing a workspace library is immediately visible to its consumers because they resolve through the link to the canonical source.
# Inspect the symlink structure in a project's node_modules
ls -la node_modules/@workspace/
# Resolve the canonical target of a symlinked package
realpath node_modules/@workspace/core
# Find broken symlinks across the tree
find . -type l ! -exec test -e {} \; -print
pnpm workspace and linker configuration
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
# .npmrc
# Isolated layout: each package gets a strict symlinked node_modules
node-linker=isolated
# Surface unmet peer dependencies instead of masking resolution errors
strict-peer-dependencies=true
On-disk internals: hard links and the store
Hard links share an inode, so the same bytes appear under multiple paths with no duplication and no path-traversal overhead. Unlike symlinks they cannot cross filesystem boundaries or point at directories. pnpm downloads each unique file once into a global content-addressable store keyed by hash, then hard-links those files into a per-project virtual store; build tools reuse the same primitive — when tuning Turborepo Pipeline Configuration, Turborepo hard-links cached outputs into place to hydrate the local cache without copying.
# Hard-link count > 1 confirms a shared inode
stat -c "%h %i" node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/index.js
# Find every name that shares one cached artifact's inode
find . -samefile .turbo/cache/build-abc123/dist/index.js
Choosing a node-linker mode
pnpm exposes three node-linker strategies, and the choice changes both the link layout and the strictness guarantees you get. The default — isolated — is what gives pnpm its phantom-dependency protection.
| Mode | Layout | Trade-off |
|---|---|---|
isolated (default) |
Symlinked tree backed by the .pnpm store; only declared deps are reachable |
Strictest; catches undeclared imports but a few legacy tools dislike the symlinks |
hoisted |
Flat node_modules like npm/Yarn classic; workspace packages still symlinked |
Maximum compatibility; reintroduces phantom-dependency risk |
pnp |
No on-disk node_modules; resolution via a manifest |
Fastest installs and smallest footprint; requires loader support |
For a monorepo, isolated is the production-recommended default precisely because the symlink structure enforces that a package can import only what it declares. Switching to hoisted to placate a tool that walks node_modules directly should be a last resort — it trades away the strictness that makes the symlink approach worth the complexity.
Why hard links save disk and time
The disk-efficiency win is concrete enough to measure. In a repo with twenty packages that each depend on the same version of a 5 MB library, an npm-style flat or per-package copy stores that library up to twenty times. pnpm stores it once in the content-addressable store and hard-links it into each package's slot in the .pnpm virtual store. The link count on the inode rises with each reference, but the bytes exist once.
# A widely-shared file shows a high hard-link count for one inode
stat -c "%n links=%h inode=%i" \
node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/index.js
This is also why a fresh pnpm install in a second project on the same machine is so fast: the files already exist in the store, so the install is mostly creating links rather than downloading and writing bytes. The cost is that the store and the project must share a filesystem — hard links cannot cross device boundaries — which is the root of the most common failure mode covered in the broken-symlinks fix page below.
Toolchain configuration and overrides
Link behavior must be declared explicitly to avoid cross-platform resolution failures. In Nx Workspace Architecture, the workspaces field and Nx cache settings govern how links are generated; platform teams should pin node-linker and validate cache hydration so CI runners do not corrupt the inode layout.
Cross-platform symlink fallback (Node.js 18+)
Windows requires elevated privileges or Developer Mode for symlink creation. This helper degrades gracefully to a directory junction, then a hard link, then a copy.
import { symlink, link, copyFile } from 'node:fs/promises';
import { platform } from 'node:os';
export async function createWorkspaceLink(target, linkPath) {
try {
if (platform() === 'win32') {
// Junctions bypass the symlink privilege requirement for directories
await symlink(target, linkPath, 'junction');
} else {
await symlink(target, linkPath);
}
} catch (err) {
if (err.code === 'EPERM' || err.code === 'EACCES') {
console.warn(`Symlink failed (${err.code}); trying hard link.`);
try {
await link(target, linkPath);
} catch {
console.warn('Hard link failed (cross-filesystem?); copying.');
await copyFile(target, linkPath);
}
} else {
throw err;
}
}
}
CI runner considerations
| Environment | Link strategy | Critical note |
|---|---|---|
| GitHub Actions (Ubuntu) | Symlinks + hard links | actions/cache@v4 preserves the inode structure |
| Docker (OverlayFS) | Symlinks only | Hard links across overlay layers are unsupported |
| Windows runners | Junction points | Enable Developer Mode or use the junction fallback |
Security and isolation
A shared cache is a shared trust boundary, and so is a node_modules full of links. Symlinks introduce directory-traversal risk if an untrusted package rewrites a path to escape the workspace; hard links sidestep traversal but pin files to specific inodes, complicating atomic rollback. Enforce strict workspace boundaries, disable post-install privilege escalation, and validate link targets before they reach CI.
#!/usr/bin/env bash
# Pre-commit hook: reject broken or escaping symlinks
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
BROKEN=$(find "$ROOT/node_modules" -type l ! -exec test -e {} \; -print 2>/dev/null)
if [ -n "$BROKEN" ]; then
echo "Broken symlinks detected:"; echo "$BROKEN"; exit 1
fi
ESCAPED=$(find "$ROOT/node_modules" -type l -exec readlink -f {} \; | grep -v "^$ROOT" || true)
if [ -n "$ESCAPED" ]; then
echo "Security violation: symlinks pointing outside the workspace root:"
echo "$ESCAPED"; exit 1
fi
Hardening checklist
- Set
unsafe-perm=falsein.npmrcso post-install scripts cannot escalate privileges. - Configure bundlers explicitly —
resolve.preserveSymlinks: true(Webpack) orresolve.symlinks: false(Vite) — only when strict package identity is required. - Mount CI cache volumes
noexec,nosuidto block traversal execution. - Run
pnpm install --frozen-lockfilein CI to enforce deterministic, reproducible link resolution.
Links in Docker and layered filesystems
Containerized CI is where link strategies most often break, because Docker's OverlayFS does not preserve hard links across image layers. A package installed in one RUN layer and referenced from another may lose its shared-inode relationship, so a build that relies on pnpm's hard links can silently fall back to copies — inflating image size and slowing the build. The fix is to keep the install and the work that depends on it in the same layer, and to copy the lockfile first so dependency installation is cached independently of source changes.
# Install dependencies in one layer so links stay intact within it
FROM node:20-slim AS deps
WORKDIR /app
RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/ packages/
RUN pnpm install --frozen-lockfile
# Build from the same dependency layer
FROM deps AS build
COPY . .
RUN pnpm exec turbo run build --filter='...[origin/main]'
For multi-stage builds, turbo prune --scope=<app> --docker emits a pruned workspace containing only the target app and its dependencies, with the lockfile split into a separate layer. That keeps the dependency-install layer cacheable across source-only changes and avoids dragging the entire monorepo into every image.
Symlinks survive OverlayFS fine — it is specifically the hard-link inode sharing that does not cross layers — so the symlinked workspace structure that resolves your packages keeps working; only the disk-deduplication benefit is lost. If image size matters, mounting the pnpm store as a build cache restores most of it.
Common pitfalls and mitigation
| Mistake | Impact | Resolution |
|---|---|---|
| Hard links for mutable workspace packages | State bleed across concurrent builds; corrupted node_modules |
Restrict hard links to the store and dist/; symlink workspace packages |
| Ignoring Windows symlink privileges | CI failures or silent fallback to full copies | Enable Developer Mode on runners or use the junction fallback |
| Mixing link strategies in cache layers | Inode confusion during parallel task execution | Standardize: hard links for cache, symlinks for workspace |
Omitting bundler preserveSymlinks flags |
Duplicate package instances in production bundles | Configure the resolver in vite.config.ts / webpack.config.js |
Unvalidated symlinks in node_modules |
Path-traversal via a malicious postinstall |
Run the pre-commit validation; set unsafe-perm=false |
Frequently Asked Questions
When should I force hard links over symlinks? Use hard links only for immutable build caches and artifact storage. Never use them for active workspace packages — concurrent writes to a shared inode corrupt dependency state and break build reproducibility.
How do production bundlers handle workspace symlinks?
Vite and Webpack resolve symlinks to their real paths by default. Enable resolve.preserveSymlinks: true in Webpack or resolve.symlinks: false in Vite only when you need strict package identity for peer-dependency validation.
Does pnpm's node-linker=hoisted eliminate symlinks?
No. hoisted flattens the dependency tree into one node_modules directory but still symlinks workspace packages. Use node-linker=isolated for strict symlink-based resolution, the recommended default for monorepos.
What are the security implications of unvalidated workspace symlinks?
An unvalidated symlink can be aimed outside the workspace root, letting a malicious package read or write where it should not. Enforce workspace policies, set unsafe-perm=false, and audit link targets in pre-commit and CI before they can be exploited.
Related
- Fixing Broken Symlinks in pnpm node_modules — diagnose and repair dangling links after a moved store or interrupted install.
- Cross-Package Dependency Management — declare the dependencies these links physically wire together.
- Workspace Configuration Deep Dive — the
node-linkerand workspace settings that shape the install layout. - Nx Workspace Architecture — how a different runner generates and caches links.