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

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.

pnpm store, hard links, and symlinks A content-addressable store holds one copy of each package; hard links place files into a virtual store, and symlinks wire those into each project's node_modules. ~/.pnpm-store one copy per file, by hash .pnpm/ store hard links to store inodes app/node_modules symlinks lib/node_modules symlinks hard link symlink
One physical copy in the store is hard-linked into a virtual store, then symlinked into each project's 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.

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) 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).
The problem statement — the core idea of this section at a glance.

The problem the symlink-and-hard-link model solves is that a naive monorepo installs a full copy of every dependency for every package, so disk usage and install time grow with the number of packages times their dependencies. pnpm's approach — a single content-addressed store, hard-linked into each package and symlinked into each package's node_modules — pays for each dependency version once on disk regardless of how many packages use it, and turns a warm install into a fast linking operation. Understanding the two link types is what lets you reason about why installs are fast, why disk is small, and why certain tools occasionally need configuration to cooperate.

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.

Symlink resolution node_modules entries symlink into the content-addressed store. node_modules/pkg symlink .pnpm/store real files realpath resolve single identity
Symlinks point each package at one real copy in the store.
# 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

Symlinks are how pnpm gives each package a node_modules that contains exactly its declared dependencies while storing each dependency only once. An entry in a package's node_modules is a symlink pointing into the content-addressed store, so require/import resolution follows the link to the single real copy, and Node's realpath resolution means the resolved module identity is that one store location regardless of how many packages link to it. This is what gives pnpm both its strictness — a package sees only what it declared — and its single-instance guarantee for shared dependencies.

The symlink structure is also why pnpm surfaces phantom dependencies that a flat node_modules hides. Because a package's node_modules contains only its declared dependencies (as symlinks) rather than a hoisted flat pile, an import of an undeclared package simply does not resolve — the link is not there. What is a silent, works-by-accident situation under hoisting becomes an immediate, local failure under symlinks, which is the strictness that catches dependency bugs at their source.

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.

Copies vs hard links Independent copies versus hard-linked store entries. Copied per project • N projects, N copies • disk grows linearly • slow cold installs Hard-linked store • one physical inode • shared across projects • near-instant installs
Hard links let many projects share one physical file on disk.
# 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.

The store is content-addressed, which means each file is stored under a key derived from the hash of its contents. Two packages that happen to contain an identical file share a single stored copy, and a corrupted or substituted file does not match its address and is detected. This is why deduplication does not weaken integrity: every project hard-linking to a store file is linking to content that has been verified against its hash, so a single verified copy safely serves many projects rather than each needing to download and verify its own. The store is thus both the mechanism for disk savings and a point of integrity verification.

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.

Disk and install cost Relative disk and install cost of copies vs a shared store. Copied node_modules 100 Hard-linked store 22 Warm store install 6
A shared store collapses duplicated disk and install time.
# 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.

The disk savings come from the fact that a hard link is a second directory entry for an existing inode, not a copy of the file. When pnpm installs a package version, it writes each file once into the content-addressed store and hard-links it into the location a project's symlinks point at, so a hundred projects on the same machine that all depend on one library share a single physical copy of each of its files. The dependency's disk cost is paid once regardless of how many projects use it, which is why a machine with many pnpm projects uses dramatically less disk than the same projects under npm's per-project copies.

The time savings follow from the same mechanism. A cold install populates the store by downloading and writing files; every install after that, for any project needing the same versions, is a linking operation — creating directory entries — rather than a download-and-write. Creating hard links is orders of magnitude faster than copying file contents, so a warm store turns installation from a network-and-disk-bound operation into a near-instant one. This is the architectural reason pnpm is both the most disk-efficient and among the fastest package managers: work done once is reused everywhere on the machine.

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.

Toolchain configuration and overrides Link behavior must be declared explicitly to avoid cross-platform resolution failures. Toolchain configuration and overrides Link behavior must be declared explicitly to avoid cross-platform resolution failures.
Toolchain configuration and overrides — the core idea of this section at a glance.

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

Some tools do not follow symlinks the way Node does, which is the source of most symlink-related friction. Bundlers, watchers, and older tooling may resolve a symlinked dependency to its real store path and then fail to find peers, or may not watch the real file for changes. The fixes are tool-specific — a bundler option to preserve symlinks, a watcher configured to follow them, or in stubborn cases pnpm's node-linker=hoisted to fall back to a flat layout — but the diagnosis is consistent: a tool that misbehaves under pnpm usually assumes a flat node_modules and needs to be told to respect the symlinked one.

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.

Security and isolation A shared cache is a shared trust boundary, and so is a nodemodules full of links. Security and isolation A shared cache is a shared trust boundary, and so is a nodemodules full of links.
Security and isolation — the core idea of this section at a glance.
#!/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=false in .npmrc so post-install scripts cannot escalate privileges.
  • Configure bundlers explicitly — resolve.preserveSymlinks: true (Webpack) or resolve.symlinks: false (Vite) — only when strict package identity is required.
  • Mount CI cache volumes noexec,nosuid to block traversal execution.
  • Run pnpm install --frozen-lockfile in CI to enforce deterministic, reproducible link resolution.

The symlinked layout has a security dimension that the flat model lacks: because a package's node_modules contains only its declared dependencies, a package cannot reach an undeclared package even if it is present elsewhere in the store. This strictness limits what a compromised or misbehaving package can import to exactly what it declared, which is a modest but real isolation benefit — a package cannot silently start depending on something a sibling installed. Combined with running installs with ignored scripts, the layout keeps the install surface both strict and free of arbitrary lifecycle code.

The content-addressed store also supports integrity guarantees. Each stored file is addressed by the hash of its content, so a corrupted or substituted file does not match its address and is detected, and the lockfile's integrity hashes verify each package on install. This means the deduplication that saves disk does not weaken verification — every project linking to a shared store file is linking to content that has been verified against its hash, so a single verified copy serves many projects safely rather than requiring each to re-verify its own copy.

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.

Links in Docker and layered filesystems Containerized CI is where link strategies most often break, because Docker's OverlayFS does not preserve hard links acro 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.
Links in Docker and layered filesystems — the core idea of this section at a glance.
# 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
Common pitfalls and mitigation Common pitfalls and mitigation in production JavaScript package workflows. Common pitfalls and mitigation Common pitfalls and mitigation in production JavaScript package workflows.
Common pitfalls and mitigation — the core idea of this section at a glance.

The recurring symlink pitfalls come from tools that assume a flat node_modules and do not follow the links the way Node's resolver does. A bundler that resolves a symlinked dependency to its real store path and then cannot find its peers, a file watcher that watches the link instead of the real file, or a container build that copies a node_modules and orphans links pointing at a store outside the copied path — each is a case of a tool not respecting the symlinked-and-hard-linked layout. The mitigations are tool-specific but the diagnosis is consistent: a tool misbehaving under pnpm usually needs to be told to preserve or follow symlinks.

The escape hatch, when a tool genuinely cannot be made to cooperate, is pnpm's node-linker=hoisted, which produces a flat node_modules at the cost of the strictness and deduplication the symlinked layout provides. Reaching for it should be a last resort, scoped as narrowly as possible, because it reintroduces the phantom-dependency and disk-duplication problems the default layout prevents. In most cases the better fix is the tool's own preserve-symlinks option, which keeps the benefits of the strict layout while satisfying the tool's resolution expectations.

Hard links, the store, and why they save disk and time

Beneath the symlinks that shape each package's node_modules lies a second mechanism: hard links from the content-addressed store to the actual files on disk. When pnpm installs a package version, it stores each file once in the global store and hard-links it into the location the symlinks point at, so a hundred projects on the same machine that all depend on one library share a single physical copy of each of its files. A hard link is a second directory entry for the same inode, not a copy, so the disk cost of the shared dependency is paid once regardless of how many projects use it.

Disk and install cost Relative disk and install cost of copies versus a shared store. Copied node_modules 100 Hard-linked store 22 Warm store install 6
A hard-linked store collapses duplicated disk and turns installs into fast linking.

The payoff is both disk and time. Disk usage collapses because duplicated dependencies across projects become shared inodes rather than repeated bytes, and installs get dramatically faster because a warm store turns installation into a linking operation — creating directory entries — rather than downloading and writing files. A cold install populates the store; every install after that, for any project needing the same versions, is near-instant. This is the architectural reason pnpm is both the most disk-efficient and among the fastest package managers: the store plus hard links means work done once is reused everywhere on the machine.

Links in Docker and layered filesystems

The symlink-and-hard-link model interacts with containerized and layered filesystems in ways worth planning for, because those environments do not always preserve links across boundaries. Copying a node_modules between Docker build stages, or across a layer boundary, can break symlinks that point at a store outside the copied path, and some overlay filesystems do not support hard links across layers, which defeats the store's deduplication. The result is a container that works locally but fails to resolve modules, or an image far larger than expected.

Links in Docker and layered filesystems The symlink-and-hard-link model interacts with containerized and layered filesystems in ways worth planning for, because Links in Docker and layered filesystems The symlink-and-hard-link model interacts with containerized and layered filesystems in ways worth planning for, because those environments do not always preser
Links in Docker and layered filesystems — the core idea of this section at a glance.

The robust patterns are to keep the store and the project in the same copied context, or to install fresh inside the container rather than copying a host-built node_modules. Using pnpm's fetch-and-install in the container, with the store on a cached layer or a mounted volume, preserves both the links and the deduplication; mounting the store as a build cache keeps installs fast across image builds. The principle is that the links are relative to a store location, so any operation that moves the project without the store — or across a filesystem that cannot represent the links — must be replaced by a fresh install that recreates them in place.

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.

Why does pnpm use so much less disk than npm?

pnpm stores each package version once in a content-addressed store and hard-links its files into each project's node_modules. A hard link is another directory entry for the same inode, not a copy, so a dependency shared across many projects is paid for once on disk rather than duplicated per project.

Why does my bundler fail to resolve modules under pnpm?

Some tools resolve a symlinked dependency to its real store path and then cannot find peers, or do not follow the link. Enable the tool's preserve-symlinks option, or fall back to pnpm's node-linker=hoisted for a flat layout the tool expects.

Why do symlinks break in my Docker build?

Copying a host-built node_modules across build stages or layer boundaries can orphan symlinks that point at a store outside the copied path, and some overlay filesystems can't hard-link across layers. Install fresh inside the container, keeping the store on a cached layer or mounted volume.

Do I need to understand symlinks and hard links to use pnpm?

Not for everyday use — pnpm handles them transparently. You need the model when a tool misbehaves (enable preserve-symlinks) or a container build breaks the links (install in the image rather than copying node_modules), which is when knowing that node_modules is links into a store makes the fix obvious.

Why is pnpm so much more disk-efficient than npm?

pnpm stores each package version once in a content-addressed store and hard-links its files into every project's node_modules. A hard link is another directory entry for the same file, not a copy, so a dependency shared across many projects is paid for once on disk rather than duplicated per project.

Related

Monorepo Architecture & Orchestration