Isolate pnpm Workspace Dependencies with Frozen Lockfiles
Stop phantom dependencies in a pnpm monorepo by enforcing isolated node_modules, one committed lockfile, and CI installs that fail on drift.
12 Feb 2026, 08:51 UTC

The problem: undeclared dependencies slip through monorepo installs
In a JavaScript monorepo, one package can import a dependency that another workspace package declared but it did not. This is a phantom dependency: it resolves at runtime because a sibling's node_modules entry is physically present or hoisted, but the import is missing from the importing package's own package.json. The code works locally and breaks in production or for a new contributor.
Useful takeaway: pnpm workspaces with the default isolated node-linker make every package resolve only its declared dependencies. A single committed pnpm-lock.yaml plus pnpm install --frozen-lockfile in CI fails fast when package.json and the lockfile disagree.
Prerequisites
- Node.js installed on the machine or CI runner.
- Corepack enabled so pnpm can be managed through Node.js. Run
corepack enableonce in your terminal. On some systems this requires administrator or sudo permissions. - A repository root with a package.json that pins pnpm through the
packageManagerfield, for example"packageManager": "pnpm@9.x". Replace 9.x with the major version you intend to use and check pnpm release notes before upgrading, because node-linker and hoisting defaults change between major versions. - A
pnpm-workspace.yamlfile at the repository root listing workspace packages, commonlypackages/*. - Each workspace package must declare its own dependencies explicitly in its package.json.
Set up workspace isolation
Create pnpm-workspace.yaml at the repo root:
packages:
- 'packages/*'
Run this in the repository root. No special permissions are required beyond file write access.
Pin pnpm in the root package.json so every developer and CI runner uses the same binary:
{
"name": "monorepo-root",
"private": true,
"packageManager": "pnpm@9.x",
"scripts": {
"install:ci": "pnpm install --frozen-lockfile",
"build": "pnpm -r build",
"test": "pnpm -r test"
}
}
Replace pnpm@9.x with your pinned major version. The -r flag runs the script recursively across workspace packages.
Run pnpm install once in the repository root. This generates pnpm-lock.yaml and installs dependencies into the default isolated layout: node_modules contains symlinks into pnpm's content-addressable store, and packages that do not declare a dependency cannot resolve it.
Commit pnpm-lock.yaml. Do not commit node_modules; reproducibility comes from the lockfile and the store, not vendored modules.
Declare intra-repo packages safely
When one workspace package depends on another, use the workspace protocol so the lockfile records the local relationship instead of resolving to the public registry:
{
"name": "@example/web-app",
"dependencies": {
"@example/ui-kit": "workspace:*",
"react": "^18.2.0"
}
}
Use workspace:* for internal packages during development. If the package is published to a registry, use workspace:^ or workspace:~ so the dependency keeps range semantics after the protocol is rewritten at publish time.
Keep hoisting targeted, not blanket
Keep the default node-linker so node_modules stays isolated. If a legacy build tool physically scans node_modules and fails, avoid shamefully-hoist=true because it reintroduces phantom dependencies. Prefer a targeted pattern in .npmrc:
public-hoist-pattern[]=*esbuild*
Add only the patterns the broken tool requires, then verify with pnpm config get node-linker.
Verify isolation and lockfile behavior
Run these checks in the repository root:
pnpm --versionshould match thepackageManagerfield after Corepack is enabled.pnpm why <pkg>explains why a dependency is present and which packages depend on it.pnpm lsshows the dependency tree for the root;pnpm -r lsshows it for every workspace package.- Add an import of an undeclared package to one workspace package and run
pnpm -r buildor your typechecker. The build or typecheck should fail, proving isolation. Remove the import afterward. - Edit a dependency in package.json, then run
pnpm install --frozen-lockfile. It should exit non-zero because the lockfile no longer matches the manifests. Runpnpm installto regenerate the lockfile and review the diff before committing. pnpm config get node-linkerand inspect.npmrcto confirm linker and hoist settings match your intended policy.
These checks do not modify committed state except the final lockfile regeneration, which should be reviewed before commit.
Run CI with a frozen lockfile
In CI, run the install explicitly with --frozen-lockfile so intent is clear and failures are deterministic:
pnpm install --frozen-lockfile
pnpm -r build
pnpm -r test
Run these commands in the repository root on the CI user account, which needs read access to the repository and write access to the local pnpm store directory. If a contributor edits package.json without regenerating the lockfile, the first command exits non-zero and the pipeline stops before build or test.
Recovery options
- Lockfile mismatch: run
pnpm installlocally, review thepnpm-lock.yamldiff, and commit the updated lockfile. - Phantom dependency: add the missing dependency explicitly to that package's package.json, regenerate the lockfile, and commit.
- Hoisting regression: remove
shamefully-hoist=truefrom.npmrcand usepublic-hoist-patternonly for the specific patterns required. - Corrupted store or modules: delete
node_modules, runpnpm store prune, then runpnpm install --frozen-lockfileto restore from the committed lockfile.
If the setup procedure changed committed files, roll back by reverting pnpm-lock.yaml, pnpm-workspace.yaml, and root package.json to their previous commits, deleting node_modules, and running pnpm install with the previous lockfile.
Limitations and version assumptions
- The isolated layout can break tools that physically scan node_modules rather than resolving through the Node.js module algorithm. Treat
node-linker=hoistedas a last resort. - Config keys and defaults vary by pnpm major version. Pin pnpm via
packageManagerand review release notes before upgrading. - The
workspace:*protocol is rewritten at publish time; published packages needworkspace:^orworkspace:~for range semantics. - Frozen-lockfile behavior is automatic in some CI providers, but pass
--frozen-lockfileexplicitly regardless of provider.
Practical verification
After setup, confirm pnpm --version matches the root package.json packageManager field, then run pnpm install --frozen-lockfile in CI and confirm it succeeds on a clean tree and fails after an uncommitted manifest change.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.