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.

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=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.

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

Monorepo Architecture & Orchestration