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/storeon 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/.pnpmfolder holding the real package files. - Symlink — top-level
node_modules/foosymlinks into.pnpmso 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-api— Fastify 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
| Need | pnpm | npm | Yarn Berry | Bun |
|---|---|---|---|---|
| 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; deletepackage-lock.jsonandyarn.lockafter 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.jsoninstead of enabling shameful hoist. - Unpinned pnpm version — lockfile format shifts between majors; set
packageManagerand use Corepack in CI. - Docker without store cache — every build re-downloads tarballs; mount
store-diror use BuildKit cache mounts. - Tools that patch node_modules — some postinstall scripts assume flat trees; test
patch-packageand native addons under pnpm before migrating production repos. - Forgetting
workspace:*at publish — usepnpm publishfrom CI so workspace ranges rewrite to real versions; raw tarballs withworkspace:break consumers. - Running npm commands accidentally —
npm installafter pnpm migration creates conflicting trees; aliasnpmto print a warning in team docs.
Production checklist
- Install pnpm via Corepack or standalone script; pin version in
packageManager. - Add
pnpm-workspace.yamlfor monorepos; useworkspace:*for internal packages. - Commit
pnpm-lock.yaml; remove other package manager lockfiles. - CI runs
pnpm install --frozen-lockfilewithcache: pnpm. - Document
pnpmcommands in README; addenginesfield if Node version matters. - Configure root
.npmrcfor registry, peers, and optionalstore-dirin Docker. - Use
--filterin CI for affected packages on large monorepos. - Audit with
pnpm audit; documentpnpm.overridesfor emergency transitive pins. - Test native addons and postinstall hooks before deleting npm lockfile on legacy apps.
- Publish internal packages with
pnpm publishso workspace protocol resolves correctly.
Key takeaways
- pnpm deduplicates packages via a global content-addressable store and hard links — faster installs, less disk.
- Strict
node_modulesprevents phantom dependencies that npm hoisting hides. - Workspaces +
workspace:*link internal packages withoutnpm linkfragility. --frozen-lockfilein CI guarantees reproducible builds.- pnpm pairs with Node; choose Bun only when you also want a new runtime and toolchain.
Related reading
- Node.js fundamentals explained — the runtime pnpm installs packages for
- Monorepo architecture explained — boundaries, CI, and tooling at scale
- Bun fundamentals explained — when one binary replaces Node plus the package manager
- Vite fundamentals explained — typical dev server in pnpm workspace frontends