DEV Community

Cover image for pnpm workspaces en monorepo: el setup que sobrevivió CI en Railway y los problemas que los docs no anticipan
Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

pnpm workspaces en monorepo: el setup que sobrevivió CI en Railway y los problemas que los docs no anticipan

pnpm workspaces en monorepo: el setup que sobrevivió CI en Railway y los problemas que los docs no anticipan

La solución correcta para acelerar installs en un monorepo TypeScript es agregar más restricciones a la resolución de paquetes. Sé que suena raro — la intuición dice "si algo falla, aflojá la configuración". Pero con pnpm workspaces, aflojar el hoisting es exactamente lo que convierte un CI estable en un CI que falla de maneras distintas cada vez.

Mi tesis es esta: pnpm workspaces es la mejor opción para monorepos TypeScript en 2026, pero el path de felicidad de los docs esconde tres trampas que solo aparecen en CI con deployment real. No son edge cases raros. Son exactamente las cosas que pasan cuando el tutorial de 5 pasos funciona en local y el primer deploy en Railway devuelve un error que no aparece en ningún README.

Este post no es una guía de setup inicial. Es el análisis de lo que viene después del setup — cuando ya tenés el pnpm-workspace.yaml, el monorepo levanta localmente y CI empieza a romperse de formas que no tienen documentación directa.


El estado real de pnpm workspaces: qué dicen los docs y qué omiten

La documentación oficial de pnpm workspaces explica bien la mecánica base: un archivo pnpm-workspace.yaml en la raíz define los paquetes, workspace:* como protocolo para dependencias internas y pnpm install desde la raíz resuelve todo el grafo. Hasta ahí todo claro.

Lo que los docs no dicen explícitamente es qué pasa cuando ese grafo se reconstruye en un entorno CI sin el store local de pnpm. En una máquina de desarrollo, el content-addressable store de pnpm actúa como caché global y muchos errores de resolución se enmascaran. En Railway, cada build arranca desde cero — y ahí aparecen las trampas.

El setup mínimo que funciona como base:

# pnpm-workspace.yaml — en la raíz del repo
packages:
  - 'apps/*'      # Next.js, APIs, servicios
  - 'packages/*'  # UI components, utils, config compartida
Enter fullscreen mode Exit fullscreen mode
// package.json raíz  scripts de orquestación
{
  "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

Esto funciona. El problema viene cuando empezás a agregar complejidad real — un paquete compartido que usa una dependencia que otra app también usa, pero desde otra versión.


Las tres trampas que los docs no anticipan

Trampa 1: Phantom dependencies en CI

Las phantom dependencies son el problema más silencioso de pnpm workspaces. En npm y Yarn Classic, el node_modules flat permite que cualquier paquete importe cualquier otro que esté instalado en el árbol — aunque no lo declare como dependencia. pnpm, por diseño, rompe eso: cada paquete solo puede acceder a lo que declara explícitamente.

El problema es que en local, si alguna dependencia directa tiene a lodash como dependencia propia, puede que lo estés usando sin declararlo y funcione. En CI desde cero, la resolución puede variar y ese import explota.

// ❌ Esto puede funcionar en local y fallar en CI
// apps/dashboard/src/utils.ts
import { debounce } from 'lodash' // lodash no está en apps/dashboard/package.json

// ✅ La solución es declarar la dependencia explícitamente
// apps/dashboard/package.json
{
  "dependencies": {
    "lodash": "^4.17.21"
  }
}
Enter fullscreen mode Exit fullscreen mode

La manera de diagnosticar esto antes de que CI lo encuentre:

# Corré esto desde la raíz — lista dependencias usadas pero no declaradas
pnpm --filter='./apps/dashboard' ls --depth 0

# Alternativa: forzá la resolución estricta en local
# .npmrc en la raíz
node-linker=isolated
Enter fullscreen mode Exit fullscreen mode

Con node-linker=isolated, pnpm crea node_modules con symlinks reales en lugar del modo por defecto. Hace que las phantom dependencies fallen en local antes de llegar a CI.

Trampa 2: shamefully-hoist en Railway — el trade-off que nadie te cuenta

La documentación de shamefully-hoist es honesta: el nombre es intencional, es una concesión de compatibilidad que pnpm considera un mal necesario. Lo que no explica es el patrón de falla específico en Railway.

Railway ejecuta el build desde el directorio del servicio que desplegás — no desde la raíz del monorepo. Si configurás shamefully-hoist=true en el .npmrc raíz, ese setting aplica en un pnpm install desde la raíz. Pero Railway, según cómo esté configurado el service, puede correr pnpm install desde apps/api y el .npmrc raíz no siempre se propaga como esperás.

# .npmrc en la raíz — esto NO garantiza que Railway lo use si instala desde un subdirectorio
shamefully-hoist=true
Enter fullscreen mode Exit fullscreen mode

La solución más robusta no es shamefully-hoist. Es identificar qué paquete necesita el hoist y declararlo correctamente:

# .npmrc en la raíz — más granular y predecible en CI
# En lugar de hoist global, especificá qué paquetes necesitan ser hoisted
hoist-pattern[]=*eslint*
hoist-pattern[]=*prettier*
hoist-pattern[]=*typescript*
Enter fullscreen mode Exit fullscreen mode

Esto hoistea solo las herramientas de desarrollo que realmente necesitan estar en el root node_modules — el caso más común son linters y el compilador de TypeScript cuando los configs están en la raíz. El resto de las dependencias mantiene la resolución estricta.

Para Railway específicamente, la configuración que tiende a ser más estable es deployar desde la raíz y configurar el build command del servicio para que filtre:

# Build command en Railway para el servicio apps/api
pnpm --filter=api build
Enter fullscreen mode Exit fullscreen mode
# Install command en Railway — instalá desde la raíz siempre
pnpm install --frozen-lockfile
Enter fullscreen mode Exit fullscreen mode

--frozen-lockfile es crítico en CI. Sin él, pnpm puede intentar actualizar el lockfile si encuentra inconsistencias — y eso puede enmascarar problemas reales o generar builds no reproducibles.

Trampa 3: Script filtering que no filtra lo que creés

pnpm --filter es poderoso pero tiene un comportamiento específico con las dependencias entre workspaces que confunde a casi todo el mundo la primera vez.

# Esto NO hace lo que parece en un monorepo con dependencias internas
pnpm --filter=dashboard build

# Si dashboard depende de packages/ui, este comando puede fallar
# porque packages/ui no está buildeado todavía
Enter fullscreen mode Exit fullscreen mode

El flag --filter selecciona el paquete pero no resuelve el orden de build del grafo de dependencias internas automáticamente — a menos que uses el flag correcto:

# ✅ Esto sí buildea en el orden correcto del grafo
pnpm --filter=dashboard... build
# Los tres puntos significan: "dashboard y todo lo que dashboard depende"

# ✅ O más explícito todavía: build recursivo en orden topológico
pnpm -r --filter=dashboard... build
Enter fullscreen mode Exit fullscreen mode

La documentación menciona esto, pero la diferencia entre --filter=dashboard y --filter=dashboard... está en una nota al pie que es fácil de saltear.

El otro gotcha con filtering: --parallel y el orden topológico son mutuamente excluyentes. Si usás --parallel, pnpm ejecuta los scripts en paralelo sin respetar el grafo de dependencias. Útil para dev (donde querés todos los watchers levantados), peligroso para build.

# ✅ dev en paralelo — todos los watchers al mismo tiempo
pnpm --filter='./apps/*' --parallel dev

# ❌ build en paralelo — puede fallar si apps/dashboard depende de packages/ui
pnpm --filter='./apps/*' --parallel build

# ✅ build respetando el grafo — más lento pero correcto
pnpm -r build
Enter fullscreen mode Exit fullscreen mode

Errores comunes de configuración y cómo diagnosticarlos

Más allá de las tres trampas principales, hay un conjunto de errores de configuración que aparecen repetidamente en setups de monorepos con pnpm:

Lockfile desincronizado entre branches: Si dos branches modifican dependencias de paquetes distintos del monorepo y se mergean sin resolver el lockfile correctamente, CI puede pasar en ambas branches y fallar después del merge. --frozen-lockfile en CI convierte esto en un fallo ruidoso en lugar de un build silenciosamente inconsistente.

workspace:* vs versiones fijas: El protocolo workspace:* resuelve a la versión actual del paquete en el workspace. Esto es lo correcto para desarrollo. Pero si algún script de build o publicación no reemplaza workspace:* por la versión real antes de empaquetar, el paquete publicado no funciona fuera del monorepo. pnpm tiene pnpm publish --recursive que hace este reemplazo, pero si usás un builder custom en Railway, verificá que esto esté contemplado.

TypeScript paths y aliases que no atraviesan el build: Un patrón común es definir @ui/* como alias de TypeScript en el tsconfig.json raíz, tener packages/ui como workspace y que todo funcione en local con el language server. En CI, si el builder de apps/dashboard no hereda los path aliases correctamente, el build falla con errores de módulo no encontrado.

// tsconfig.base.json en la raíz
{
  "compilerOptions": {
    "paths": {
      "@ui/*": ["./packages/ui/src/*"]
    }
  }
}

// tsconfig.json en apps/dashboard  debe extender la base
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "baseUrl": "."
  }
}
Enter fullscreen mode Exit fullscreen mode

Checklist: antes de hacer deploy a Railway con pnpm workspaces

Esto no es una garantía — es el conjunto de verificaciones que reduce la probabilidad de sorpresas en CI. Cada ítem es reproducible localmente:

  • [ ] pnpm install --frozen-lockfile pasa sin modificar el lockfile — si falla, hay una inconsistencia que hay que resolver antes de CI
  • [ ] Cada app buildea limpia desde la raíz con pnpm --filter=<app>... build — con los tres puntos para incluir dependencias internas
  • [ ] No hay phantom dependencies: pnpm --filter=<app> ls --depth 0 no muestra dependencias que no estén declaradas en el package.json del paquete
  • [ ] El .npmrc no usa shamefully-hoist=true sin motivo concreto — si lo necesitás, usá hoist-pattern[] con los paquetes específicos
  • [ ] Railway está configurado para instalar desde la raíz, no desde el subdirectorio del servicio — esto es configurable en el dashboard de Railway bajo "Root Directory"
  • [ ] El build command en Railway usa --filter con el nombre exacto del paquete según el campo name en su package.json, no el nombre del directorio
  • [ ] workspace:* se reemplaza correctamente si algún paquete se publica o se empaqueta fuera del monorepo

FAQ: pnpm workspaces en CI con Railway

¿Cuál es la diferencia entre pnpm -r build y pnpm --filter='./apps/*' build?

pnpm -r build ejecuta el script build en todos los paquetes del workspace que lo tengan definido, respetando el orden topológico del grafo de dependencias. pnpm --filter='./apps/*' build ejecuta build solo en los directorios bajo apps/, pero si esos paquetes dependen de algo en packages/, ese algo tiene que estar ya buildeado. Para CI, -r es más seguro. Para builds selectivos, usá --filter=<app>... con los tres puntos.

¿Por qué --frozen-lockfile es obligatorio en CI y no en local?

En local, pnpm puede actualizar el lockfile si encuentra que una dependencia cambió o si el lockfile no está completamente sincronizado. En CI, eso significa builds no reproducibles: dos corridas del mismo commit pueden instalar versiones distintas si el lockfile se actualiza entre medio. --frozen-lockfile hace que pnpm falle inmediatamente si el lockfile no coincide exactamente con el estado del package.json — lo que convertís en ruido audible en lugar de fallo silencioso.

¿Cuándo tiene sentido usar shamefully-hoist=true y cuándo no?

Tiene sentido como solución temporal cuando migrás un repo que venía de npm o Yarn Classic y tenés phantom dependencies masivas que no podés resolver de a una. Como estado permanente, no. El nombre refleja la postura de pnpm al respecto. La alternativa granular con hoist-pattern[] te da compatibilidad donde la necesitás (herramientas de CLI que buscan módulos en el root) sin comprometer el resto.

¿workspace:* o workspace:^ para dependencias internas?

workspace:* es la convención más usada y la que recomiendan los docs. Significa "la versión exacta que está en el workspace". workspace:^ permite compatibilidad semántica. Para paquetes internos de un monorepo que evolucionan juntos, workspace:* es más predecible — si rompés la API de packages/ui, querés que apps/dashboard falle explícitamente, no que intente resolver una versión compatible que ya no existe.

¿Cómo configuro Railway para que instale desde la raíz del monorepo?

En el dashboard de Railway, en la configuración del servicio, el campo "Root Directory" debería estar vacío o apuntar a la raíz del repo — no al subdirectorio del app. El "Build Command" debería ser algo como pnpm --filter=<nombre-del-app> build. Si dejás "Root Directory" apuntando al subdirectorio, Railway no va a encontrar el pnpm-workspace.yaml ni el lockfile raíz y el install va a fallar o generar un node_modules inconsistente.

¿Vale la pena pnpm workspaces sobre Turborepo o Nx para un monorepo TypeScript pequeño?

pnpm workspaces resuelve la instalación y la resolución de dependencias. Turborepo y Nx agregan una capa de orquestación de tasks con caché de outputs. Para un monorepo pequeño (dos o tres apps, uno o dos paquetes compartidos), pnpm workspaces solo es suficiente y es menos configuración. El salto a Turborepo empieza a justificarse cuando el pnpm -r build tarda más de lo que podés tolerar y necesitás caché de outputs — que es un problema diferente al de la resolución de dependencias.


Mi postura y el límite honesto de este análisis

pnpm workspaces es la herramienta correcta para monorepos TypeScript en 2026. El modelo de resolución estricta con el content-addressable store es mejor que el hoisting flat de npm o Yarn Classic — no por dogma, sino porque hace explícitas las dependencias que realmente necesitás declarar. Ese rigor es el que hace que las phantom dependencies exploten en local en lugar de en producción.

Lo que no compro es la narrativa de que "con pnpm todo funciona solo". El gap entre el tutorial de 5 pasos y un monorepo con tres apps, dos paquetes compartidos y deploy en Railway tiene fricción real. Phantom dependencies, hoisting config y script filtering son exactamente esa fricción — y vale la pena conocerla antes de encontrarla en un deploy fallido.

Lo que este análisis no puede garantizarte: las tres trampas que describí son patrones comunes documentados y reproducibles, pero el comportamiento exacto depende de las versiones específicas de pnpm (≥9 tiene algunos cambios de comportamiento respecto a v8), de cómo esté configurado el runtime de Railway en el momento en que leas esto y de la topología específica de tu monorepo. Los comandos y configs de este post son reproducibles — los resultados exactos en CI son función de variables que no controlo.

El próximo paso concreto: si tenés un monorepo con pnpm workspaces y querés validar que no tenés phantom dependencies antes de que CI las encuentre, empezá con node-linker=isolated en el .npmrc de desarrollo y corrés un pnpm install limpio. Si algo se rompe localmente, mejor ahora.


Para más contexto sobre decisiones de arquitectura en el stack TypeScript — cómo pienso el diseño de tokens de autenticación, el problema de caching en Next.js App Router o por qué Zod se rompe de tres maneras distintas en runtime — están en el blog.


Fuentes originales:


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)