pnpm workspaces in a monorepo: the setup that survived CI on Railway and the problems the docs don't warn you about
The correct fix for speeding up installs in a TypeScript monorepo is adding more constraints to package resolution. I know that sounds backwards — intuition says "if something's broken, loosen the config." But with pnpm workspaces, loosening hoisting is exactly what turns a stable CI into one that fails in a different way every single time.
My thesis: pnpm workspaces is the best option for TypeScript monorepos in 2026, but the happy path in the docs hides three traps that only appear in CI with real deployments. These aren't rare edge cases. They're exactly what happens when the 5-step tutorial works fine locally and the first Railway deploy throws an error that appears in no README anywhere.
This post isn't a setup guide. It's an analysis of what comes after setup — when you already have the pnpm-workspace.yaml, the monorepo boots locally, and CI starts breaking in ways that have no direct documentation.
The real state of pnpm workspaces: what the docs say and what they leave out
The official pnpm workspaces documentation does a decent job explaining the base mechanics: a pnpm-workspace.yaml at the root defines packages, workspace:* as the protocol for internal dependencies, and pnpm install from the root resolves the whole graph. That part's clear.
What the docs don't say explicitly is what happens when that graph gets rebuilt in a CI environment with no local pnpm store. On a dev machine, pnpm's content-addressable store acts as a global cache and masks a lot of resolution errors. On Railway, every build starts from scratch — and that's where the traps appear.
The minimal setup that works as a base:
# pnpm-workspace.yaml — at the repo root
packages:
- 'apps/*' # Next.js, APIs, services
- 'packages/*' # UI components, utils, shared config// root package.json — orchestration scripts
{
"private": true,
"scripts": {
"build": "pnpm --filter='./apps/*' build",
"dev": "pnpm --filter='./apps/*' dev --parallel",
"typecheck": "pnpm -r typecheck"
},
"engines": {
"node": ">=20",
"pnpm": ">=9"
}
}This works. The problem starts when you add real complexity — a shared package that uses a dependency that another app also uses, but from a different version.
The three traps the docs don't warn you about
Trap 1: Phantom dependencies in CI
Phantom dependencies are the quietest problem in pnpm workspaces. With npm and Yarn Classic, the flat node_modules lets any package import anything that's installed in the tree — even if it's not declared as a dependency. pnpm breaks that by design: each package can only access what it explicitly declares.
The catch is that locally, if a direct dependency happens to have lodash as its own dependency, you might be using it without declaring it and it just works. In CI starting from scratch, the resolution can vary and that import blows up.
// ❌ This can work locally and fail in CI
// apps/dashboard/src/utils.ts
import { debounce } from 'lodash' // lodash is not in apps/dashboard/package.json
// ✅ The fix is declaring the dependency explicitly
// apps/dashboard/package.json
{
"dependencies": {
"lodash": "^4.17.21"
}
}The way to diagnose this before CI finds it:
# Run this from the root — lists used but undeclared dependencies
pnpm --filter='./apps/dashboard' ls --depth 0
# Alternative: force strict resolution locally
# .npmrc at the root
node-linker=isolatedWith node-linker=isolated, pnpm creates node_modules with real symlinks instead of the default mode. It makes phantom dependencies fail locally before they ever reach CI.
Trap 2: shamefully-hoist on Railway — the trade-off nobody tells you about
The documentation for shamefully-hoist is honest: the name is intentional. It's a compatibility concession that pnpm itself considers a necessary evil. What it doesn't explain is the specific failure pattern on Railway.
Railway runs the build from the directory of the service you're deploying — not from the monorepo root. If you set shamefully-hoist=true in the root .npmrc, that setting applies when pnpm install is run from the root. But Railway, depending on how the service is configured, might run pnpm install from apps/api — and the root .npmrc doesn't always propagate the way you'd expect.
# .npmrc at the root — this does NOT guarantee Railway uses it if it installs from a subdirectory
shamefully-hoist=trueThe more robust solution isn't shamefully-hoist. It's identifying which package actually needs the hoist and declaring it correctly:
# .npmrc at the root — more granular and predictable in CI
# Instead of global hoist, specify which packages need to be hoisted
hoist-pattern[]=*eslint*
hoist-pattern[]=*prettier*
hoist-pattern[]=*typescript*This only hoists the dev tools that genuinely need to live in the root node_modules — the most common case being linters and the TypeScript compiler when configs live at the root. Everything else keeps strict resolution.
For Railway specifically, the configuration that tends to be most stable is deploying from the root and setting the service's build command to filter:
# Build command in Railway for the apps/api service
pnpm --filter=api build# Install command in Railway — always install from the root
pnpm install --frozen-lockfile--frozen-lockfile is critical in CI. Without it, pnpm might try to update the lockfile if it finds inconsistencies — which can either mask real problems or generate non-reproducible builds.
Trap 3: Script filtering that doesn't filter what you think
pnpm --filter is powerful but has specific behavior around inter-workspace dependencies that trips up almost everyone the first time.
# This does NOT do what it looks like in a monorepo with internal dependencies
pnpm --filter=dashboard build
# If dashboard depends on packages/ui, this command can fail
# because packages/ui isn't built yetThe --filter flag selects the package but doesn't automatically resolve the build order for the internal dependency graph — unless you use the right flag:
# ✅ This builds in the correct graph order
pnpm --filter=dashboard... build
# The three dots mean: "dashboard and everything dashboard depends on"
# ✅ Or even more explicit: recursive build in topological order
pnpm -r --filter=dashboard... buildThe docs do mention this, but the difference between --filter=dashboard and --filter=dashboard... is buried in a footnote that's easy to skip.
The other gotcha with filtering: --parallel and topological order are mutually exclusive. If you use --parallel, pnpm runs scripts in parallel without respecting the dependency graph. Useful for dev (where you want all watchers up at the same time), dangerous for build.
# ✅ dev in parallel — all watchers at the same time
pnpm --filter='./apps/*' --parallel dev
# ❌ build in parallel — can fail if apps/dashboard depends on packages/ui
pnpm --filter='./apps/*' --parallel build
# ✅ build respecting the graph — slower but correct
pnpm -r buildCommon config mistakes and how to diagnose them
Beyond the three main traps, there's a cluster of configuration errors that keep showing up in pnpm monorepo setups:
Lockfile out of sync between branches: If two branches modify dependencies in different packages and merge without resolving the lockfile correctly, CI can pass on both branches and fail after the merge. --frozen-lockfile in CI turns this into a loud failure instead of a silently inconsistent build.
workspace:* vs pinned versions: The workspace:* protocol resolves to the current version of the package in the workspace. That's correct for development. But if some build or publish script doesn't replace workspace:* with the real version before packaging, the published package won't work outside the monorepo. pnpm has pnpm publish --recursive which does this replacement — but if you're using a custom builder on Railway, verify this is covered.
TypeScript paths and aliases that don't survive the build: A common pattern is defining @ui/* as a TypeScript alias in the root tsconfig.json, having packages/ui as a workspace, and everything working locally with the language server. In CI, if the builder for apps/dashboard doesn't correctly inherit the path aliases, the build fails with module-not-found errors.
// tsconfig.base.json at the root
{
"compilerOptions": {
"paths": {
"@ui/*": ["./packages/ui/src/*"]
}
}
}
// tsconfig.json in apps/dashboard — must extend the base
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "."
}
}Checklist: before deploying to Railway with pnpm workspaces
This isn't a guarantee — it's the set of checks that reduces the probability of surprises in CI. Every item is reproducible locally:
-
pnpm install --frozen-lockfilepasses without modifying the lockfile — if it fails, there's an inconsistency to fix before CI sees it - Each app builds clean from the root with
pnpm --filter=<app>... build— with the three dots to include internal dependencies - No phantom dependencies:
pnpm --filter=<app> ls --depth 0shows no dependencies that aren't declared in the package'spackage.json - The
.npmrcdoesn't useshamefully-hoist=truewithout a concrete reason — if you need it, usehoist-pattern[]with the specific packages - Railway is configured to install from the root, not from the service subdirectory — this is configurable in the Railway dashboard under "Root Directory"
- The build command in Railway uses
--filterwith the exact package name as specified in thenamefield of itspackage.json, not the directory name -
workspace:*gets replaced correctly if any package is published or packaged outside the monorepo
FAQ: pnpm workspaces in CI with Railway
What's the difference between pnpm -r build and pnpm --filter='./apps/*' build?
pnpm -r build runs the build script in every workspace package that has it defined, respecting topological order in the dependency graph. pnpm --filter='./apps/*' build runs build only in directories under apps/, but if those packages depend on something in packages/, that something needs to already be built. For CI, -r is safer. For selective builds, use --filter=<app>... with the three dots.
Why is --frozen-lockfile required in CI but not locally?
Locally, pnpm can update the lockfile if a dependency changed or if the lockfile isn't fully in sync. In CI, that means non-reproducible builds: two runs of the same commit might install different versions if the lockfile gets updated in between. --frozen-lockfile makes pnpm fail immediately if the lockfile doesn't exactly match the state of package.json — turning silent failure into loud, audible noise.
When does shamefully-hoist=true make sense and when doesn't it?
It makes sense as a temporary fix when migrating a repo that came from npm or Yarn Classic and you have massive phantom dependencies you can't resolve one by one. As a permanent state — no. The name reflects pnpm's own opinion on the matter. The granular alternative with hoist-pattern[] gives you compatibility where you actually need it (CLI tools that look for modules in the root) without compromising everything else.
workspace:* or workspace:^ for internal dependencies?
workspace:* is the most common convention and what the docs recommend. It means "the exact version that's in the workspace." workspace:^ allows semantic compatibility. For internal packages in a monorepo that evolve together, workspace:* is more predictable — if you break the API of packages/ui, you want apps/dashboard to fail explicitly, not try to resolve a compatible version that no longer exists.
How do I configure Railway to install from the monorepo root?
In the Railway dashboard, in the service configuration, the "Root Directory" field should be empty or pointing to the repo root — not to the app subdirectory. The "Build Command" should be something like pnpm --filter=<app-name> build. If you leave "Root Directory" pointing to the subdirectory, Railway won't find the pnpm-workspace.yaml or the root lockfile, and the install will either fail or generate an inconsistent node_modules.
Is pnpm workspaces worth it over Turborepo or Nx for a small TypeScript monorepo?
pnpm workspaces solves installation and dependency resolution. Turborepo and Nx add a task orchestration layer with output caching. For a small monorepo (two or three apps, one or two shared packages), pnpm workspaces alone is enough and means less configuration. The jump to Turborepo starts making sense when pnpm -r build takes longer than you can tolerate and you need output caching — which is a different problem from dependency resolution.
My take and the honest limits of this analysis
pnpm workspaces is the right tool for TypeScript monorepos in 2026. The strict resolution model with the content-addressable store is better than the flat hoisting of npm or Yarn Classic — not out of dogma, but because it makes explicit the dependencies you actually need to declare. That rigor is exactly what makes phantom dependencies blow up locally instead of in production.
What I don't buy is the narrative that "with pnpm everything just works." The gap between the 5-step tutorial and a monorepo with three apps, two shared packages, and a Railway deployment has real friction. Phantom dependencies, hoisting config, and script filtering are exactly that friction — and it's worth knowing about them before you hit them in a failed deploy.
What this analysis can't guarantee you: the three traps I described are common, documented, reproducible patterns — but the exact behavior depends on your specific version of pnpm (≥9 has some behavioral changes from v8), how Railway's runtime is configured at the time you read this, and the specific topology of your monorepo. The commands and configs in this post are reproducible. The exact CI results are a function of variables I don't control.
The concrete next step: if you have a monorepo with pnpm workspaces and you want to validate there are no phantom dependencies before CI finds them, start with node-linker=isolated in your dev .npmrc and run a clean pnpm install. If something breaks locally, better now than later.
For more context on architecture decisions in the TypeScript stack — how I think about authentication token design, the Next.js App Router caching problem, or why Zod breaks in three distinct ways at runtime — those are on the blog.
Original sources:
- pnpm Workspaces — Official documentation: https://pnpm.io/workspaces
- pnpm — .npmrc settings: shamefully-hoist: https://pnpm.io/npmrc#shamefully-hoist
Related Articles
pnpm vs npm vs yarn vs bun: The Real Comparison Nobody Gives You in 2025
I used all four in real projects. One wrecked a monorepo at 3am. Another saved my ass in production. Here's the unfiltered truth about every major package manager in 2025.
OAuth 2.0 Scope Creep: the Attack Vector the Vercel Incident Exposed and How to Audit It in Your Integrations
The Vercel incident wasn't a technical vulnerability — it was a least-privilege failure applied to OAuth. Break down what scope creep is, how to audit it in existing integrations, and what architectural controls prevent a third party from accumulating permissions it doesn't need.
Functional programming in TypeScript: the abstractions I actually use and the ones I dropped
I started wanting to write Haskell in TypeScript and ended up with three helpers and a lesson. An honest breakdown of which functional patterns survive in a real TypeScript codebase and which ones collapse under team friction or the type checker.
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.