Escritos

Monorepo con Turborepo: gestionar cuatro aplicaciones en un solo repositorio

Explico cómo configurar pnpm workspaces + Turborepo a partir de un repositorio real: 4 apps, 4 paquetes, paquetes sin compilar, un bug de outputs que rompe la caché en silencio y cómo contenerizar una sola app con Docker.

Furkan Özay12 de junio de 2025 · 10 min de lectura
czCZAY.DEV // EDITORIALES
MODERN WEB & REACT

#Araçlar · 10 MIN DE LECTURA

FURKAN ÖZAYVOL. 2025 // #B4A9

La mayoría de los artículos sobre monorepos empiezan en un directorio vacío y terminan con «y así de fácil es». La parte difícil no está ahí: si compilar o no los paquetes compartidos, cómo vas a saber realmente si la caché funciona y cómo contenerizar una sola app con Docker.

Este artículo se basa en un repositorio de producción: el mismo repo que aloja el sitio que estás leyendo ahora mismo. Contiene cuatro apps y cuatro paquetes compartidos, y una de ellas ni siquiera es Next.js.

Primero: ¿realmente necesitas un monorepo?

Consejo: Cuándo es necesario

Si varias apps comparten el mismo código y ese código tiene que cambiar en ambas en un solo commit, un monorepo es la elección correcta. Design tokens, componentes compartidos, definiciones de tipos comunes, reglas de lint compartidas.

Advertencia: Cuándo no es necesario

Si las apps son independientes, un monorepo solo añade complejidad: builds de CI más largos, despliegues más complejos e historiales de git enredados entre sí. Montar un monorepo bajo la premisa de que «quizá compartamos código más adelante» significa pagar el coste antes de tener la necesidad.

En mi caso, el código compartido era real: czay.dev y fit.czay.dev comparten exactamente los mismos design tokens. Actualizar un color por separado en dos repos distintos hacía inevitable que las versiones se desviaran.

¿Qué pinta tiene el repositorio?

plaintext
czay.dev/
├── apps/
│   ├── web/          → czay.dev          (Next.js)
│   ├── student/      → student.czay.dev  (Next.js + Drizzle + Postgres)
│   ├── fit/          → fit.czay.dev      (Next.js)
│   └── links/        → links.czay.dev    (Astro)
├── packages/
│   ├── ui/           → @czay-dev/ui           (tokens + shared components)
│   ├── mdx/          → @czay-dev/mdx          (MDX options, slug generation)
│   ├── tsconfig/     → @czay-dev/tsconfig     (shared TS config)
│   └── eslint-config/→ @czay-dev/eslint-config
├── pnpm-workspace.yaml
├── turbo.json
└── package.json

El grafo de dependencias es sencillo: web → ui + mdx, fit → ui, mientras que student y links no usan ningún paquete compartido. Esto es crucial porque toda la velocidad de Turborepo viene de entender este grafo: si ui no ha cambiado, no necesita recompilar fit.

Que links use Astro es una elección intencionada: a Turborepo no le importa qué framework uses. Solo gestiona el grafo de tareas y la caché; cada app se encarga de compilar con sus propias herramientas.

Configurar el workspace de pnpm

Con dos archivos basta.

Ejemplo: pnpm-workspace.yaml

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

Ejemplo: El package.json de una app

JSON
{
  "name": "web",
  "dependencies": {
    "@czay-dev/ui": "workspace:*",
    "@czay-dev/mdx": "workspace:*"
  }
}

El protocolo workspace:* es la clave: en lugar de descargar esta dependencia de npm, pnpm crea un enlace simbólico al directorio del repositorio. Cuando pasas a un paquete publicado, el protocolo se convierte automáticamente en un número de versión real, pero si no publicas, ni siquiera tienes que pensar en ello.

No compiles los paquetes (la mayoría de las veces)

Esta es la decisión que más tiempo hace perder al configurar monorepos. Hay dos enfoques:

Paquete compilado: el paquete ejecuta su propio paso build para generar dist/, y las aplicaciones consumen el resultado compilado. Ventaja: el paquete se puede publicar en npm. Coste: tienes que recompilar el paquete en cada cambio, necesitas procesos de watch configurados durante dev y los source maps se complican.

Paquete JIT (just-in-time): el paquete exporta el código fuente de TypeScript directamente y delega el paso de compilación en la app que lo consume.

Ejemplo: packages/ui/package.json

JSON
{
  "name": "@czay-dev/ui",
  "exports": {
    "./styles/globals.css": "./src/styles/globals.css",
    "./components/button": "./src/components/button.tsx",
    "./lib/utils": "./src/lib/utils.ts"
  }
}

Fíjate: no hay dist/, ni main, ni un script build. Apunta directamente al archivo .tsx.

A cambio, tienes que decirle al bundler de tu aplicación: «transpila también este paquete»:

Ejemplo: apps/web/next.config.ts

TypeScript
const nextConfig: NextConfig = {
	transpilePackages: ["@czay-dev/ui", "@czay-dev/mdx"],
};

La recompensa de esta elección se nota en dev: cuando modificas un archivo de un paquete, el fast refresh de tu app reacciona al instante, sin ningún paso de compilación en medio. A menos que vayas a publicar el paquete en npm, JIT es casi siempre la elección correcta.

turbo.json

Ejemplo: turbo.json

JSON
{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
      "env": ["DB_POSTGRES_URL", "RESEND_API_KEY", "NEXT_PUBLIC_*"]
    },
    "lint": { "dependsOn": ["^lint"] },
    "typecheck": { "dependsOn": ["^typecheck"] },
    "dev": { "cache": false, "persistent": true }
  }
}

Cuatro detalles:

dependsOn: ["^build"] — El ^ inicial significa «la misma tarea en mis dependencias». Antes de compilar web, hay que compilar ui y mdx. Para los paquetes JIT, esta tarea no hace nada (los paquetes no tienen script build), pero declarar bien el grafo significa que no tendrás que cambiar nada si más adelante un paquete necesita un paso de compilación.

dev: { cache: false, persistent: true } — El servidor de desarrollo es una tarea de larga duración que nunca termina. persistent se lo indica a Turborepo, mientras que cache: false evita que se guarde su salida en la caché. Si lo omites, Turbo se queda colgado esperando a que la tarea termine.

La lista permitida de env — Es el origen de los bugs más sutiles. Turborepo genera las claves de caché a partir de las entradas; si una variable de entorno no está en esta lista, no se incluye en el hash. Así, cuando actualizas NEXT_PUBLIC_API_URL y recompilas, Turbo dice «las entradas no han cambiado» y devuelve la salida en caché, desplegando tu app con la URL antigua. Toda variable que afecte al build debe estar en esta lista.

outputs — Y aquí cuento mi propio error.

Precaución: La caché que se rompió en silencio

Durante mucho tiempo, mis outputs eran así: [".next/**", "!.next/cache/**"]. Como tres de las cuatro apps eran Next.js, parecía correcto. Pero links es una app de Astro y escribe su salida de build en dist/.

El resultado: Turbo cacheaba la tarea de build de links, pero no encontraba ningún archivo que guardar. En la siguiente ejecución, informaba de un «cache hit», reproducía los logs del build y dist/ no estaba por ninguna parte. Ningún mensaje de error, solo archivos que faltaban.

La solución: [".next/**", "!.next/cache/**", "dist/**"]

La regla que se extrae de aquí: la caché puede parecer que funciona cuando no lo hace. Cuando cachees una tarea, borra la carpeta de salida, ejecuta turbo run build y comprueba visualmente que los archivos reaparecen de verdad.

Uso diario

Bash
pnpm dev                      # all apps at once
pnpm build                    # in order according to dependency graph
pnpm typecheck                # tsc --noEmit in every package
 
turbo run dev --filter=web    # only czay.dev
turbo run build --filter=web... # web and its dependencies
turbo run build --filter=...ui  # ui and everything dependent on it

Los puntos en la sintaxis de --filter indican la dirección: web... significa «web y sus dependencias», mientras que ...ui significa «ui y todo lo que depende de ella». Esto último es la forma más rápida de ver qué podría romperse al editar un paquete.

Los puertos se asignan para evitar conflictos: web en el 3000, fit en el 3002, student en el 3003.

Contenerizar una sola app de un monorepo con Docker

Aquí es donde más se atascan las personas con los monorepos. Despliego la app student en mi propio servidor con Dokploy; la imagen de Docker se construye desde la raíz del repo porque ahí viven pnpm-lock.yaml y los paquetes compartidos.

1. Salida standalone y la raíz correcta en Next.js

Ejemplo: apps/student/next.config.ts

TypeScript
import path from "node:path";
 
const nextConfig = {
	output: "standalone" as const,
	outputFileTracingRoot: path.join(import.meta.dirname, "../.."),
};

output: "standalone" configura Next.js para que rastree únicamente los node_modules que se usan de verdad y los empaquete en un directorio ejecutable independiente.

outputFileTracingRoot es específico de los monorepos y si te lo saltas, tu imagen fallará en tiempo de ejecución: pnpm eleva (hoist) las dependencias al node_modules de la raíz del repo, mientras que Next.js por defecto solo rastrea dentro del directorio de la app y pasa por alto esos paquetes elevados. Apuntarlo a la raíz le indica al rastreador de archivos que busque más arriba.

2. Instala solo los paquetes necesarios

Ejemplo: Dockerfile — capa de dependencias

dockerfile
FROM node:22-alpine AS deps
RUN corepack enable
WORKDIR /repo
 
# Copy manifest files first: when source code changes, this layer
# comes from cache, so pnpm install doesn't re-run.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/student/package.json apps/student/
COPY packages/ui/package.json packages/ui/
COPY packages/mdx/package.json packages/mdx/
COPY packages/tsconfig/package.json packages/tsconfig/
COPY packages/eslint-config/package.json packages/eslint-config/
 
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile --filter student...

Aquí hay dos trucos:

  • Copia primero los manifiestos. La caché de capas de Docker comprueba las modificaciones de archivos; si copias el código fuente antes de pnpm install, las dependencias se reinstalan con cada cambio de código. Esta separación reduce los tiempos de build de minutos a segundos.
  • --filter student... — Solo se instalan student y sus dependencias. Las dependencias de fit y web nunca entran en la imagen.

3. Copia a la imagen runner solo lo necesario

dockerfile
FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
 
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/static ./apps/student/.next/static
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/public ./apps/student/public
 
USER nextjs

En la imagen runner no hay pnpm, ni árbol de código fuente, ni otras apps: solo Node y la salida del build standalone.

Explicación: El bug que solo me pilló en producción

La carpeta public/ estaba vacía y git no rastrea las carpetas vacías. Todo funcionaba en local porque la carpeta existía en mi máquina, pero en un clon limpio la carpeta no existía en absoluto, lo que hacía fallar el paso COPY en tiempo de ejecución con «not found». La solución fue añadir una sola línea al paso de build:

dockerfile
RUN mkdir -p apps/student/public

Este tipo de bugs son comunes en los monorepos: las discrepancias entre el estado de la máquina local y un clon limpio aparecen en muchos más sitios que en los repositorios de una sola app.

Resumen

Resumen

  • Monta un monorepo solo si tienes código compartido; no lo montes «por si acaso»
  • Enlaza los paquetes con workspace:* y no los compiles: los paquetes JIT + transpilePackages te dan un ciclo de feedback mucho más rápido
  • Si te faltan entradas en la lista permitida env de turbo.json, se servirá una caché obsoleta y tu app se desplegará con variables de entorno desactualizadas
  • Si a outputs le faltan rutas, la caché devuelve resultados vacíos en silencio: borra el directorio de salida y verifica
  • Construye las imágenes de Docker desde la raíz, copia primero los manifiestos e instala solo lo necesario con --filter app...
  • Configura outputFileTracingRoot con la raíz del repo en los builds standalone de Next.js, o la imagen de runtime fallará

El verdadero valor de Turborepo no es solo la velocidad: es entender con precisión el grafo. Una vez que defines qué app depende de qué paquete, él decide qué hay que compilar y qué viene de la caché. Si lo defines mal, como en mi bug de outputs, te da la respuesta equivocada en silencio y, además, rápido.

Hoja de ruta y herramienta interactivas

Sigue leyendo