Artikel

Monorepo mit Turborepo: vier Apps in einem Repository verwalten

Anhand eines echten Repositorys zeige ich, wie du pnpm Workspaces und Turborepo einrichtest – mit 4 Apps, 4 Packages, nicht kompilierten Packages, einem Outputs-Bug, der das Caching still kaputt macht, und einer einzelnen App im Docker-Container.

Furkan Özay12. Juni 2025 · 9 Min. Lesezeit
czCZAY.DEV // EDITORIALDE
MODERN WEB & REACT

#Araçlar · 9 MIN LESEZEIT

FURKAN ÖZAYVOL. 2025 // #B4A9

Die meisten Artikel über Monorepos starten in einem leeren Verzeichnis und enden mit „So einfach ist das.“ Dort liegt aber nicht die Schwierigkeit: Sie liegt in der Frage, ob du geteilte Packages baust oder nicht, woran du wirklich erkennst, dass das Caching funktioniert, und wie du eine einzelne App mit Docker containerisierst.

Dieser Beitrag basiert auf einem Produktiv-Repository – genau dem Repo, das die Seite hostet, die du gerade liest. Es enthält vier Apps und vier geteilte Packages, und eine davon ist nicht einmal Next.js.

Zuerst: Brauchst du überhaupt ein Monorepo?

Tipp: Wann es nötig ist

Wenn mehrere Apps denselben Code teilen und dieser Code sich in einem einzigen Commit in beiden ändern muss, ist ein Monorepo die richtige Wahl. Design Tokens, geteilte Komponenten, gemeinsame Typdefinitionen, gemeinsame Lint-Regeln.

Warnung: Wann es nicht nötig ist

Wenn die Apps unabhängig voneinander sind, bringt ein Monorepo nur Komplexität: längere CI-Builds, aufwendigere Deployments und eine verflochtene Git-Historie. Ein Monorepo unter der Annahme aufzusetzen, „wir teilen später vielleicht Code“, heißt, die Kosten zu zahlen, bevor du den Bedarf hast.

Bei mir war das Teilen real: czay.dev und fit.czay.dev nutzen exakt dieselben Design Tokens. Eine Farbe in zwei getrennten Repos getrennt zu aktualisieren, machte Versionsdrift unvermeidlich.

Wie sieht das Repository aus?

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

Der Abhängigkeitsgraph ist übersichtlich: web → ui + mdx, fit → ui, während student und links keine geteilten Packages nutzen. Das ist entscheidend, denn die gesamte Geschwindigkeit von Turborepo kommt daher, dass es diesen Graphen versteht – wenn sich ui nicht geändert hat, muss fit nicht neu gebaut werden.

Dass links Astro nutzt, ist eine bewusste Entscheidung: Turborepo ist egal, welches Framework du verwendest. Es verwaltet nur den Task-Graphen und den Cache; jede App baut mit ihrem eigenen Tooling.

pnpm Workspace einrichten

Du brauchst nur zwei Dateien.

Beispiel: pnpm-workspace.yaml

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

Beispiel: package.json der App

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

Das workspace:*-Protokoll ist der Schlüssel: Statt diese Abhängigkeit von npm herunterzuladen, legt pnpm einen Symlink auf das Verzeichnis im Repository an. Wenn du auf ein veröffentlichtes Package umsteigst, wird das Protokoll automatisch in eine echte Versionsnummer umgewandelt; wenn du nichts veröffentlichst, musst du gar nicht darüber nachdenken.

Baue Packages nicht (meistens)

Das ist die Entscheidung, die in Monorepo-Setups die meiste Zeit verschwendet. Es gibt zwei Ansätze:

Kompiliertes Package: Das Package führt einen eigenen build-Schritt aus, der dist/ erzeugt, und die Anwendungen nutzen die gebaute Ausgabe. Vorteil: Das Package lässt sich auf npm veröffentlichen. Preis: Du musst das Package bei jeder Änderung neu bauen, brauchst während dev eingerichtete Watch-Prozesse, und Source Maps werden unübersichtlich.

JIT-Package (Just-in-Time): Das Package exportiert TypeScript-Quellcode direkt und überlässt den Build-Schritt der App, die es verwendet.

Beispiel: 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"
  }
}

Beachte: kein dist/, kein main, kein build-Script. Es zeigt direkt auf die .tsx-Datei.

Dafür musst du dem Bundler deiner Anwendung sagen: „Transpiliere dieses Package auch“:

Beispiel: apps/web/next.config.ts

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

Den Nutzen dieser Entscheidung spürst du in dev: Wenn du eine Datei in einem Package änderst, reagiert Fast Refresh in deiner App sofort – es gibt keinen Build-Schritt dazwischen. Solange du das Package nicht auf npm veröffentlichst, ist JIT fast immer die richtige Wahl.

turbo.json

Beispiel: 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 }
  }
}

Vier Details:

dependsOn: ["^build"] — Das führende ^ bedeutet „derselbe Task in meinen Abhängigkeiten“. Bevor web gebaut wird, müssen ui und mdx gebaut sein. Bei JIT-Packages ist dieser Task ein No-op (die Packages haben kein build-Script), aber wenn du den Graphen korrekt deklarierst, musst du nichts ändern, falls ein Package später doch einen Build-Schritt braucht.

dev: { cache: false, persistent: true } — Der Dev-Server ist ein langlaufender Task, der nie endet. persistent teilt das Turborepo mit, während cache: false verhindert, dass seine Ausgabe gecacht wird. Lässt du das weg, hängt Turbo und wartet darauf, dass der Task beendet wird.

env-Allowlist — Hier entstehen die fiesen Bugs. Turborepo erzeugt Cache-Keys aus den Inputs; steht eine Umgebungsvariable nicht in dieser Liste, fließt sie nicht in den Hash ein. Wenn du also NEXT_PUBLIC_API_URL änderst und neu baust, sagt Turbo „Inputs haben sich nicht geändert“, liefert die gecachte Ausgabe zurück und deployt deine App mit der alten URL. Jede Variable, die den Build beeinflusst, muss in dieser Liste stehen.

outputs — Und hier teile ich meinen eigenen Fehler.

Achtung: Der Cache, der still kaputtging

Lange Zeit sahen meine outputs so aus: [".next/**", "!.next/cache/**"]. Da drei der vier Apps Next.js waren, wirkte das richtig. Aber links ist eine Astro-App und schreibt ihre Build-Ausgabe nach dist/.

Das Ergebnis: Turbo cachte den Build-Task von links, fand aber keine Dateien zum Speichern. Beim nächsten Lauf meldete es einen „Cache Hit“, spielte die Build-Logs ab, und dist/ fehlte komplett. Keine Fehlermeldung – einfach fehlende Dateien.

Die Lösung: [".next/**", "!.next/cache/**", "dist/**"]

Die Lehre daraus: Caching kann so aussehen, als würde es funktionieren, obwohl es das nicht tut. Wenn du einen Task cachst, lösche den Output-Ordner, führe turbo run build aus und prüfe mit eigenen Augen, dass die Dateien wirklich wieder auftauchen.

Alltägliche Nutzung

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

Die Punkte in der --filter-Syntax legen die Richtung fest: web... bedeutet „web und seine Abhängigkeiten“, ...ui bedeutet „ui und alles, was von ui abhängt“. Letzteres ist der schnellste Weg zu sehen, was beim Bearbeiten eines Packages kaputtgehen könnte.

Die Ports sind so vergeben, dass es keine Konflikte gibt: web auf 3000, fit auf 3002, student auf 3003.

Eine einzelne App aus einem Monorepo mit Docker containerisieren

Hier tun sich die meisten mit Monorepos am schwersten. Ich deploye die student-App mit Dokploy auf meinen eigenen Server; das Docker-Image wird aus dem Repo-Root gebaut, weil dort pnpm-lock.yaml und die geteilten Packages liegen.

1. Standalone-Output und das richtige Root in Next.js

Beispiel: 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" konfiguriert Next.js so, dass nur die tatsächlich genutzten node_modules verfolgt und in ein eigenständiges, ausführbares Verzeichnis gebündelt werden.

outputFileTracingRoot ist Monorepo-spezifisch, und wenn du es weglässt, stürzt dein Image zur Laufzeit ab: pnpm hebt Abhängigkeiten in die node_modules im Repo-Root, während Next.js standardmäßig nur innerhalb des App-Verzeichnisses verfolgt und diese gehoisteten Packages verpasst. Wenn du es auf das Root setzt, sucht der File Tracer weiter oben.

2. Nur die benötigten Packages installieren

Beispiel: Dockerfile — Dependency-Layer

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

Hier gibt es zwei Tricks:

  • Zuerst die Manifeste kopieren. Der Layer-Cache von Docker prüft Dateiänderungen; wenn du den Quellcode vor pnpm install kopierst, werden die Abhängigkeiten bei jeder Codeänderung neu installiert. Diese Trennung senkt die Build-Zeiten von Minuten auf Sekunden.
  • --filter student... — Es werden nur student und seine Abhängigkeiten installiert. Die Abhängigkeiten von fit und web landen nie im Image.

3. Nur das Nötige ins Runner-Image kopieren

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

Im Runner-Image gibt es weder pnpm noch den Quellbaum noch andere Apps – nur Node und die Standalone-Build-Ausgabe.

Erklärung: Der Bug, der mich erst in Produktion erwischte

Der Ordner public/ war leer, und Git trackt keine leeren Ordner. Lokal lief alles, weil der Ordner auf meinem Rechner existierte, aber in einem frischen Clone gab es den Ordner gar nicht, sodass der COPY-Schritt zur Laufzeit mit „not found“ abbrach. Die Lösung war eine einzelne Zeile im Build-Schritt:

dockerfile
RUN mkdir -p apps/student/public

Solche Bugs sind in Monorepos häufig: Abweichungen zwischen dem lokalen Rechnerzustand und einem frischen Clone tauchen an weit mehr Stellen auf als in Repos mit nur einer App.

Zusammenfassung

Zusammenfassung

  • Richte ein Monorepo nur ein, wenn du geteilten Code hast; nicht „für alle Fälle“
  • Verlinke Packages mit workspace:* und baue sie nicht – JIT-Packages + transpilePackages geben dir eine deutlich schnellere Feedback-Schleife
  • Wenn du Einträge in der env-Allowlist der turbo.json vergisst, wird veralteter Cache ausgeliefert und deine App geht mit veralteten Umgebungsvariablen live
  • Fehlen in outputs Pfade, liefert das Caching still leere Ergebnisse – lösche das Output-Verzeichnis und prüfe nach
  • Baue Docker-Images aus dem Root, kopiere zuerst die Manifeste und installiere mit --filter app... nur das Nötige
  • Setze outputFileTracingRoot bei Next.js-Standalone-Builds auf das Repo-Root, sonst stürzt das Runtime-Image ab

Der wahre Wert von Turborepo ist nicht nur Geschwindigkeit, sondern den Graphen korrekt zu verstehen. Sobald du definierst, welche App von welchem Package abhängt, entscheidet es, was gebaut werden muss und was aus dem Cache kommt. Definierst du ihn falsch – wie bei meinem outputs-Bug –, liefert es dir still die falsche Antwort, und zwar schnell.

Interaktive Roadmap & Tool

Als Nächstes lesen