Guide

pnpm fundamentals explained

A monorepo with twelve packages and three apps can balloon to gigabytes of duplicated node_modules when every project copies its own copy of React, lodash, and TypeScript. pnpm (performant npm) solves that with a content-addressable global store: each package version is downloaded once, then hard-linked into per-project node_modules trees. Installs finish faster, disks stay lean, and phantom dependency bugs shrink because pnpm enforces a strict, non-flat layout. You still run packages on Node.js — pnpm is the installer and linker, not a replacement runtime like Bun. This guide covers installation, daily commands, lockfiles, workspaces, configuration, CI patterns, a Harbor Commerce monorepo worked example, a package manager decision table, common pitfalls, and a production checklist alongside our monorepo architecture guide.

What problem pnpm solves

Classic npm and Yarn Classic hoist dependencies to the top of node_modules, letting code import packages it never declared in package.json. That works until a transitive dependency disappears in the next major upgrade and production crashes. Flat trees also duplicate the same tarball hundreds of times across a developer laptop and CI cache.

pnpm’s layout is strict by default. A package can only require() or import dependencies listed in its own package.json (plus their declared dependencies). Accidental imports of sibling hoisted packages fail in development instead of silently shipping to production. Combined with the global store, a team cloning a large repo often sees pnpm install complete in seconds when the store already holds the resolved versions.

Core concepts

  • Store — global directory (default ~/.local/share/pnpm/store on Linux) keyed by content hash of each package tarball.
  • Hard link — filesystem entry pointing at store content; no duplicate bytes on disk.
  • Virtual store — per-project node_modules/.pnpm folder holding the real package files.
  • Symlink — top-level node_modules/foo symlinks into .pnpm so Node resolution works.
  • Workspace — multiple packages in one repo sharing one lockfile and linked via workspace:* protocol.

Installation and enabling pnpm

Install pnpm globally via the standalone script (recommended) or Corepack shipped with Node 16.13+:

# Standalone installer (macOS/Linux)
curl -fsSL https://get.pnpm.io/install.sh | sh -

# Or enable via Corepack (Node.js)
corepack enable
corepack prepare pnpm@latest --activate

pnpm --version

Pin the pnpm major version in package.json so CI and contributors agree:

{
  "packageManager": "pnpm@9.12.0"
}

Corepack reads packageManager and downloads the matching binary. Commit that field when migrating an existing npm repo — document the switch in the README and run one PR that replaces package-lock.json with pnpm-lock.yaml.

Day-to-day commands

pnpm mirrors npm’s CLI surface with a pnpm prefix. The commands you use every sprint:

pnpm install              # install from lockfile
pnpm add lodash           # add runtime dependency
pnpm add -D vitest        # add dev dependency
pnpm remove lodash        # remove and update lockfile
pnpm update               # bump within semver ranges in package.json
pnpm exec tsc --noEmit    # run a local binary (like npx)
pnpm run build            # run package.json script
pnpm run -r build         # recursive: all workspace packages

Filtering workspaces

In monorepos, target one package without changing directory:

# Run tests only in packages matching a name pattern
pnpm --filter @harbor/commerce-ui test

# Run build in a package and everything it depends on
pnpm --filter @harbor/commerce-ui... build

# Add a dependency to one workspace package
pnpm --filter @harbor/pricing-core add zod

Filters compose with ... (dependencies), ^... (dependents), and brace globs. This is how CI runs only affected packages after a diff — pair filters with Turborepo or Nx task graphs for larger repos.

Lockfiles and reproducible installs

pnpm-lock.yaml records the exact resolved graph for every workspace package. Unlike JSON lockfiles, YAML stays readable in review and supports merge-friendly conflict markers when two branches add different packages.

CI must install in frozen mode so a stale lockfile fails the build instead of mutating on the runner:

pnpm install --frozen-lockfile

Locally, pnpm install updates the lockfile when package.json changes. Never hand-edit the lockfile; use pnpm add and pnpm remove so integrity hashes stay correct. For security audits, pnpm audit reports known CVEs; pnpm.overrides in root package.json pins transitive versions when upstream has not patched yet.

Workspaces and monorepo layout

Declare workspaces in pnpm-workspace.yaml at the repo root:

packages:
  - "apps/*"
  - "packages/*"

Internal packages reference each other with the workspace: protocol:

// apps/commerce-ui/package.json
{
  "dependencies": {
    "@harbor/pricing-core": "workspace:*"
  }
}

workspace:* resolves to the local version at publish time pnpm rewrites it to a real semver range. Shared dev tooling (ESLint, TypeScript, Prettier) lives in the root package.json as devDependencies and applies to all packages via config file inheritance.

Hoisting and public-hoist patterns

Some tools (Jest, older Webpack plugins) expect packages at the root of node_modules. pnpm offers escape hatches:

  • public-hoist-pattern[]=*eslint* — hoist matching packages for tool compatibility.
  • shamefully-hoist=true — flat layout like npm (loses strictness; use sparingly).
  • node-linker=hoisted — npm-style tree for legacy repos migrating incrementally.

Prefer fixing imports and tool config over enabling shameful hoist globally. Strict layout catches missing declarations early.

Configuration with .npmrc

pnpm reads .npmrc at the repo root (and user-level ~/.npmrc). Common production settings:

# Pin registry (private Verdaccio or npmjs)
registry=https://registry.npmjs.org/

# Fail CI if lockfile out of date (alternative to CLI flag)
frozen-lockfile=true

# Auto-install peers instead of warning
auto-install-peers=true

# Strict: only declared deps importable
strict-peer-dependencies=false

# Shared store path in Docker (mount volume here)
store-dir=/pnpm-store

In Docker multi-stage builds, mount a persistent store-dir layer between builds so pnpm install only fetches new tarballs. Copy pnpm-lock.yaml and workspace manifests before source so dependency layers cache independently of application code changes.

CI and Docker patterns

A minimal GitHub Actions job for a pnpm monorepo:

- uses: pnpm/action-setup@v4
  with:
    version: 9
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run -r lint test build

The cache: pnpm option restores both the global store and node_modules metadata. For affected-package CI, combine pnpm --filter ...[origin/main] with git diff to skip untouched workspaces. See our CI/CD pipelines guide for gate ordering: install, lint, unit tests ( Vitest), then Playwright E2E on staging.

Worked example: Harbor Commerce monorepo

Harbor Commerce maintains a merchant dashboard, a pricing microservice, and shared UI primitives in one git repo. Before pnpm, npm install at the root took four minutes and consumed 2.1 GB on disk; developers symlinked packages manually with npm link, which broke whenever two apps needed different React patch versions.

Layout after migration

  • apps/dashboard — Vite + React storefront admin.
  • apps/pricing-apiFastify HTTP service.
  • packages/pricing-core — pure fee math shared by API and UI preview.
  • packages/ui-kit — shared buttons and form components.
# Root package.json scripts
{
  "scripts": {
    "build": "pnpm run -r build",
    "test": "pnpm run -r test",
    "lint": "pnpm run -r lint"
  }
}

pricing-core publishes no npm tarball; consumers import via "@harbor/pricing-core": "workspace:*". Vitest in the API package imports the same module the dashboard uses — no publish-and-reinstall loop. CI runs pnpm install --frozen-lockfile then pnpm --filter ...[origin/main] test so pull requests touching only the UI kit skip pricing-api tests. Disk usage dropped to 680 MB; cold install on a warm store cache runs under forty seconds.

Package manager decision table

NeedpnpmnpmYarn BerryBun
Disk-efficient monorepo on Node Best fit Heavy duplication Good (PnP or node_modules) Fast but separate runtime
Strict dependency boundaries Default strict layout Hoisting hides gaps Plug’n’Play strict mode npm-compatible hoisting
Zero-config single app Works; slight learning curve Default with Node Works Fast all-in-one
Replace Node runtime too No (installer only) No No Yes
Plug’n’Play (no node_modules) Optional hoisted mode No Native PnP No

Common pitfalls

  • Mixing lockfiles — commit only pnpm-lock.yaml; delete package-lock.json and yarn.lock after migration or CI installs the wrong graph.
  • Phantom dependencies in old code — strict layout exposes imports that worked under npm hoisting; add missing entries to package.json instead of enabling shameful hoist.
  • Unpinned pnpm version — lockfile format shifts between majors; set packageManager and use Corepack in CI.
  • Docker without store cache — every build re-downloads tarballs; mount store-dir or use BuildKit cache mounts.
  • Tools that patch node_modules — some postinstall scripts assume flat trees; test patch-package and native addons under pnpm before migrating production repos.
  • Forgetting workspace:* at publish — use pnpm publish from CI so workspace ranges rewrite to real versions; raw tarballs with workspace: break consumers.
  • Running npm commands accidentallynpm install after pnpm migration creates conflicting trees; alias npm to print a warning in team docs.

Production checklist

  • Install pnpm via Corepack or standalone script; pin version in packageManager.
  • Add pnpm-workspace.yaml for monorepos; use workspace:* for internal packages.
  • Commit pnpm-lock.yaml; remove other package manager lockfiles.
  • CI runs pnpm install --frozen-lockfile with cache: pnpm.
  • Document pnpm commands in README; add engines field if Node version matters.
  • Configure root .npmrc for registry, peers, and optional store-dir in Docker.
  • Use --filter in CI for affected packages on large monorepos.
  • Audit with pnpm audit; document pnpm.overrides for emergency transitive pins.
  • Test native addons and postinstall hooks before deleting npm lockfile on legacy apps.
  • Publish internal packages with pnpm publish so workspace protocol resolves correctly.

Key takeaways

  • pnpm deduplicates packages via a global content-addressable store and hard links — faster installs, less disk.
  • Strict node_modules prevents phantom dependencies that npm hoisting hides.
  • Workspaces + workspace:* link internal packages without npm link fragility.
  • --frozen-lockfile in CI guarantees reproducible builds.
  • pnpm pairs with Node; choose Bun only when you also want a new runtime and toolchain.

Related reading