pnpm 워크스페이스 기반 모노레포: Railway CI를 견뎌낸 설정과 문서가 경고하지 않는 문제들

발행: (2026년 6월 19일 PM 09:03 GMT+9)
6 분 소요
원문: Dev.to

Source: Dev.to
pnpm 워크스페이스와 모노레포: CI에서 Railway를 견뎌낸 설정과 문서가 경고하지 않는 문제들

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 워크스페이스는 2026년 타입스크립트 모노레포에 가장 좋은 옵션이지만, 문서의 ‘행복한 경로’는 실제 배포 시 CI에서만 나타나는 세 가지 함정을 숨기고 있다. 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 — 레포 루트에 위치
packages:
   - 'apps/*'       # Next.js, APIs, services
   - 'packages/*'   # UI components, utils, shared config

// root package.json — 조율 스크립트

{ “private”: true, “scripts”: { “build”: “pnpm —filter=’./apps/’ build”, “dev”: “pnpm —filter=’./apps/’ dev —parallel”, “typecheck”: “pnpm -r typecheck” }, “engines”: { “node”: “>20”, “pnpm”: “>9” } }

Enter fullscreen mode Exit fullscreen mode

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=isolated

With 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=true

The 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 yet

The --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... build

The 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 hasn't been built yet
0 조회
Back to Blog

관련 글

더 보기 »