We didn't migrate to pnpm because it's faster (it is). We migrated because npm's dependency model has become a security liability — and we felt it directly in our project.
Here's what pushed us over the edge, and exactly what we changed in our Nx monorepo to make it work.
The Problem: npm audit was lying to us
Every npm install on our project ended the same way:
8 vulnerabilities (3 moderate, 4 high, 1 critical)
The frustrating part? None of them were in packages we declared. They lived deep inside transitive dependencies — packages installed by our packages' packages. We hadn't written a single line that imported them, yet they were sitting in our node_modules, executable, and trusted.
The --legacy-peer-deps flag we'd been using to silence peer dependency warnings was another sign something was off. It's a pressure valve, not a fix.
What Wasn't The Problem
- Our direct dependencies: Everything we explicitly installed was fine.
- Our code: No injection vectors in the application layer.
- npm itself: The registry hasn't been compromised. The problem is architectural.
Root Cause: Flat node_modules and Unrestricted Install Scripts
npm installs all packages — yours and every transitive dependency — into a single flat node_modules directory. This means your application code can require() any installed package, even packages you never declared a dependency on. This is called phantom dependency access, and supply chain attackers exploit it deliberately.
Worse: npm runs postinstall scripts automatically for every package it installs, including transitive ones. In 2025 alone, over 450,000 malicious npm packages were published. Attacks like the Shai-Hulud worm spread by compromising transitive dependencies and using their postinstall hooks to execute arbitrary code — targeting exactly this behavior.
Our --legacy-peer-deps flag was compounding this. It bypassed peer dependency resolution, meaning npm could install multiple conflicting versions of the same package without warning — widening the attack surface further.
The Migration: What We Actually Changed
We migrated to pnpm v11 across our Nx monorepo. Here's what the change looked like in practice.
1. The Lockfile Swap
The most visible change: we deleted package-lock.json (23,975 lines) and replaced it with pnpm-lock.yaml. Beyond the format difference, pnpm's lockfile is more tamper-resistant — it doesn't store tarball sources that can be silently overwritten, and it won't install packages listed in the lockfile that aren't declared in package.json.
- package-lock.json
+ pnpm-lock.yaml
We also updated .gitignore explicitly:
# lock files — commit pnpm-lock.yaml, ignore npm/yarn locks
package-lock.json
yarn.lock
!pnpm-lock.yaml
2. Blocking Install Scripts by Default
This is the biggest security win. pnpm v11 blocks postinstall and build scripts for all packages by default. To run scripts, packages must be explicitly allowlisted in pnpm-workspace.yaml:
# pnpm-workspace.yaml
allowBuilds:
'@google/genai': true
'@nestjs/core': true
'@swc/core': true
bcrypt: true
esbuild: true
nx: true
protobufjs: true
sqlite3: true
Every package on this list has a legitimate reason to run a build script (native bindings, code generation). Everything else is blocked. A compromised transitive dependency can no longer silently execute code on pnpm install.
3. The .npmrc Configuration
We added a .npmrc at the root to configure pnpm's behavior:
# Peer dependencies
auto-install-peers=true
strict-peer-dependencies=false
# Hoisting — required for NestJS + Nx compatibility
shamefully-hoist=true
# Security
audit=true
# ignore-scripts is intentionally omitted: pnpm v11 blocks all install/build
# scripts by default unless the package is listed under allowBuilds.
The shamefully-hoist=true deserves an explanation. pnpm's default behavior uses symlinked node_modules with strict isolation — each package can only access what it explicitly declares. This is the ideal security model. However, NestJS and Nx both rely on assumptions about package resolution that require hoisting. We enabled it pragmatically, with the understanding that allowBuilds still controls script execution regardless of hoisting.
4. All Dockerfiles and CI Workflows Updated
Every Dockerfile went from:
FROM node:20-bookworm AS build-stage
COPY package-lock.json ./
RUN npm ci --legacy-peer-deps
To:
FROM node:22-bookworm AS build-stage
RUN npm install -g pnpm@11.2.2
COPY pnpm-lock.yaml ./
COPY .npmrc ./
COPY pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
The --frozen-lockfile flag is important in CI and production builds: it refuses to install if the lockfile is out of sync with package.json, catching accidental dependency drift before it reaches production.
GitHub Actions workflows followed the same pattern, adding pnpm/action-setup@v4 before setup-node:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
5. Pinning Broken Transitive Releases
During migration we discovered that @nx versions 19.8.15 had packages that were never actually published to the registry, causing install failures. pnpm's overrides in pnpm-workspace.yaml let us pin them cleanly:
overrides:
'@nx/express': '19.8.14'
'@nx/vue': '19.8.14'
'@nx/next': '19.8.14'
With npm, this kind of transitive override requires npm shrinkwrap or complex workarounds. With pnpm it's three lines.
Results
-
Zero
--legacy-peer-depsanywhere in the codebase — peer deps resolve cleanly. - Install scripts are now an explicit allowlist, not an implicit free-for-all.
-
pnpm auditruns on every install and CI run automatically. - Lockfile diffs are human-readable — YAML is far easier to review in PRs than JSON.
- Faster installs in CI due to pnpm's content-addressable store (bonus, not the goal).
Key Takeaway
The npm supply chain threat isn't theoretical anymore. In 2025-2026 we saw real worms spreading through transitive dependencies using postinstall hooks. The flat node_modules model means a package three levels deep from something you care about can execute code on your machine and your CI runner.
pnpm v11's default script-blocking is a genuine security control, not a cosmetic preference. The migration has friction — you'll need to build an allowBuilds list carefully and tune hoisting for your framework. But the result is a dependency install process where you know exactly what's allowed to run.
Trusting npm install to be safe is no longer a reasonable default.
Top comments (0)