DEV Community

Cover image for Why We Migrated from npm to pnpm — and It Wasn't About Speed
alfchee
alfchee

Posted on

Why We Migrated from npm to pnpm — and It Wasn't About Speed

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

We also updated .gitignore explicitly:

# lock files — commit pnpm-lock.yaml, ignore npm/yarn locks
package-lock.json
yarn.lock
!pnpm-lock.yaml
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

With npm, this kind of transitive override requires npm shrinkwrap or complex workarounds. With pnpm it's three lines.

Results

  • Zero --legacy-peer-deps anywhere in the codebase — peer deps resolve cleanly.
  • Install scripts are now an explicit allowlist, not an implicit free-for-all.
  • pnpm audit runs 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)