# Ein Monorepo mit drei Apps: wie wir nur bauen, was sich geändert hat, und die Pipeline von acht Minuten auf drei gebracht haben

Drei Next.js-Anwendungen, vier geteilte Pakete, ein Repository. Das ist die Form der Plattform, die wir für einen US-Kunden betreiben, und es ist die richtige Form: geteilte Typen, ein Lockfile, ein Pull Request für eine Änderung, die die API und die beiden Frontends berührt, die sie konsumieren. Der falsche Teil war die Pipeline, die bei jedem Commit alle drei Anwendungen neu baute, auch bei einem Commit, der eine README änderte.

Acht Minuten pro Push. Drei CodeBuild-Jobs, drei Docker-Builds, drei Image-Pushes, ob sich in diesen Anwendungen etwas geändert hatte oder nicht. So haben wir sie bei einem typischen Commit auf drei Minuten und bei einem reinen Doku-Commit auf unter eine gebracht, mit dem Tooling, das es geschafft hat, der Falle, in die wir getappt sind, und dem einen Fall, in dem alles neu zu bauen richtig ist.

## Das Layout

```
.
├── apps/
│   ├── web/          # öffentliche Next.js-App
│   ├── admin/        # interne Next.js-App
│   └── api/          # Next.js-Route-Handler, als eigener Dienst deployt
├── packages/
│   ├── db/           # Schema, Migrationen, Query-Helfer
│   ├── ui/           # geteilte Komponenten
│   ├── config/       # eslint-, tsconfig-, tailwind-Presets
│   └── types/        # geteilte TypeScript-Typen, generierter API-Client
├── infra/            # CDK
├── pnpm-workspace.yaml
├── turbo.json
└── pnpm-lock.yaml
```

pnpm-Workspaces für den Paketgraphen, Turborepo für den Task-Graphen. Jede App hängt von einer Teilmenge der Pakete ab; `web` und `admin` hängen von `ui` ab, alle drei von `db` und `types`, alles von `config`. Dieser Abhängigkeitsgraph ist der gesamte Input für „was hat sich geändert“.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Abhängigkeitsgraph. Apps web, admin und api oben. Pakete ui, db, types und config darunter. web und admin hängen von ui ab; alle drei Apps von db und types; alles von config. Eine Änderung an ui markiert web und admin als betroffen, nicht api. Eine Änderung an db markiert alle drei. Eine Änderung an einer README markiert nichts.">
<defs><marker id="arrG" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#9aa3c7"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="120" y="30" width="150" height="50" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="195" y="60" text-anchor="middle" fill="#f1f3ff" font-weight="700">apps/web</text>
<rect x="375" y="30" width="150" height="50" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="450" y="60" text-anchor="middle" fill="#f1f3ff" font-weight="700">apps/admin</text>
<rect x="630" y="30" width="150" height="50" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="705" y="60" text-anchor="middle" fill="#f1f3ff" font-weight="700">apps/api</text>
<rect x="40" y="150" width="150" height="50" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="115" y="180" text-anchor="middle" fill="#f1f3ff">packages/ui</text>
<rect x="260" y="150" width="150" height="50" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="335" y="180" text-anchor="middle" fill="#f1f3ff">packages/db</text>
<rect x="480" y="150" width="150" height="50" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="555" y="180" text-anchor="middle" fill="#f1f3ff">packages/types</text>
<rect x="700" y="150" width="150" height="50" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="775" y="180" text-anchor="middle" fill="#f1f3ff">packages/config</text>
<line x1="170" y1="82" x2="125" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="200" y1="82" x2="320" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="230" y1="82" x2="530" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="410" y1="82" x2="140" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="440" y1="82" x2="345" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="470" y1="82" x2="545" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="690" y1="82" x2="360" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="705" y1="82" x2="570" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="740" y1="82" x2="770" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<text x="450" y="232" text-anchor="middle" fill="#9aa3c7">packages/ui ändern → web + admin bauen · packages/db ändern → alle drei bauen · README ändern → nichts bauen</text>
</g>
</svg>
</div>

## Schritt 1: Turborepo entscheiden lassen, was betroffen ist

Turborepo kennt den Graphen bereits. Die `--filter`-Syntax mit einem Git-Bereich fragt, welche Workspaces sich seit einem Commit geändert haben, direkt oder über eine Abhängigkeit:

```bash
# welche Apps brauchen für diesen Push einen Build?
pnpm turbo ls --affected --filter='./apps/*' --output=json | jq -r '.packages.items[].name'
```

`--affected` vergleicht den Arbeitsbaum standardmäßig mit `origin/main` (konfigurierbar über `TURBO_SCM_BASE`), läuft den Abhängigkeitsgraphen ab und liefert die Apps, die transitiv von etwas Geändertem abhängen. Eine Änderung an `packages/ui` liefert `web` und `admin`. Eine Änderung an `apps/api/app/orders/route.ts` liefert `api`. Eine Änderung an `README.md` liefert nichts.

Das Deploy-Skript liest diese Liste und startet pro App darin einen CodeBuild-Job, parallel, statt immer drei zu starten. Das ist die gesamte Änderung am Kontrollfluss der Pipeline: zehn Zeilen Shell, die eine hartkodierte Liste von dreien ersetzen.

Zwei Dinge zählt Turborepo, korrekt, als „betrifft alles“: das Lockfile und die Root-Konfiguration. Ein Dependency-Bump in `pnpm-lock.yaml` baut alle drei Apps neu, weil jede die neue Version aufgenommen haben könnte. Ebenso eine Änderung an `turbo.json` oder der Root-`package.json`. Wir haben einmal versucht, das Lockfile auszuschließen, um einen Routine-Bump zu beschleunigen, und eine App mit veralteter Abhängigkeit ausgeliefert. Nicht tun.

## Schritt 2: die Build-Outputs cachen, remote

Innerhalb des Builds jeder App cacht Turborepo Task-Outputs, geschlüsselt nach einem Hash der Inputs: Quelldateien, Abhängigkeiten, deklarierte Umgebungsvariablen. Ein zweiter Build mit denselben Inputs stellt den Output aus dem Cache wieder her, statt `next build` auszuführen. Lokal passiert das automatisch. In CI startet jeder CodeBuild-Job von einer leeren Platte, also muss der Cache irgendwo Geteiltes leben.

Wir betreiben einen kleinen selbst gehosteten Remote-Cache, eine Open-Source-Implementierung der Remote-Cache-API von Turborepo, auf einer `t4g.nano`, die nach S3 schreibt. Jeder CodeBuild-Job und jeder Entwicklerrechner zeigt darauf:

```json
// .turbo/config.json, oder per Env in CI
{ "teamid": "team_platform", "apiurl": "https://turbo-cache.internal" }
```

```bash
# in der Buildspec
export TURBO_TOKEN=$TURBO_CACHE_TOKEN TURBO_TEAM=team_platform TURBO_API=https://turbo-cache.internal
pnpm turbo build --filter=web
```

Der Effekt: `packages/types` und `packages/db` werden einmal gebaut, von dem Job, der zuerst dort ankommt, und jeder andere Job stellt sie in einer Sekunde wieder her. Ein `web`-Build, der eine Seite geändert hat, stellt die geteilten Pakete aus dem Cache wieder her und führt `next build` nur für `web` selbst aus. Der Remote-Cache kostet $3 im Monat für die Instanz und Cent-Beträge für S3.

## Schritt 3: auch die Docker-Layer cachen

Der Docker-Build ist die andere Hälfte der Zeit. Ein [mehrstufiges Node-Image](/de/blog/docker-images-for-node-in-2026-distroless-multi-stage) hat eine teure `pnpm install`-Schicht, die sich nur ändert, wenn das Lockfile es tut, und eine `next build`-Schicht, die sich bei jedem Commit ändert. BuildKit kann den Layer-Cache in eine Registry exportieren und beim nächsten Build importieren:

```bash
docker buildx build \
  --cache-from type=registry,ref=$ECR/web:buildcache \
  --cache-to   type=registry,ref=$ECR/web:buildcache,mode=max \
  --build-arg RELEASE_SHA=$SHA \
  -t $ECR/web:$SHA --push .
```

Mit warmem Cache wird die `pnpm install`-Schicht in zehn Sekunden wiederhergestellt, statt neunzig zu laufen. Die `next build`-Schicht läuft weiterhin, aber Turborepos Cache darin bedeutet, dass auch sie größtenteils wiederhergestellt wird. `mode=max` cacht Zwischenstufen, was die Builder-Stage wiederverwendbar macht; das Cache-Image liegt in ECR unter einem festen Tag und kostet ein paar hundert Megabyte Speicher.

## Die Zahlen

| Commit-Typ | Vorher | Nachher | Was läuft |
|---|---|---|---|
| Nur Doku, Infra, CI-Konfig | 8 Min | 40 s | Turborepo meldet nichts betroffen; Deploy-Skript überspringt alle Builds; nur CDK-Diff |
| Eine Seite in `apps/web` | 8 Min | 2 Min 50 s | Ein CodeBuild-Job; geteilte Pakete aus dem Remote-Cache; Docker-Install-Schicht aus dem Registry-Cache |
| Änderung an `packages/ui` | 8 Min | 3 Min 20 s | Zwei Jobs parallel, `web` und `admin`; `api` unberührt |
| Änderung an `packages/db` | 8 Min | 3 Min 40 s | Drei Jobs parallel; Caches treffen weiter für Install-Schichten |
| Lockfile-Bump | 8 Min | 6 Min | Drei Jobs; Install-Schicht überall neu gebaut; Turborepo-Cache invalidiert; korrekt |

Der mittlere Commit ist die zweite Zeile. Acht Minuten auf unter drei, und weil die laufenden Jobs parallel sind, ist das *Maximum* jetzt der Lockfile-Fall mit sechs, nicht die alten acht bei jedem Commit.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Balkendiagramm der Pipeline-Dauer nach Commit-Typ, vorher und nachher. Vorher: 8 Minuten für jeden Typ. Nachher: 40 Sekunden für reine Doku, 2 Minuten 50 für eine einzelne App-Änderung, 3 Minuten 20 für eine ui-Paket-Änderung, 3 Minuten 40 für eine db-Paket-Änderung, 6 Minuten für einen Lockfile-Bump.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Pipeline-Dauer nach Commit-Typ</text>
<g fill="#9aa3c7">
<text x="20" y="60">nur Doku</text><rect x="180" y="48" width="480" height="8" rx="2" fill="#2a3150"/><rect x="180" y="58" width="40" height="8" rx="2" fill="#4fffb0"/><text x="670" y="60" fill="#f1f3ff">8:00 → 0:40</text>
<text x="20" y="94">eine App</text><rect x="180" y="82" width="480" height="8" rx="2" fill="#2a3150"/><rect x="180" y="92" width="170" height="8" rx="2" fill="#4fffb0"/><text x="670" y="94" fill="#f1f3ff">8:00 → 2:50</text>
<text x="20" y="128">packages/ui</text><rect x="180" y="116" width="480" height="8" rx="2" fill="#2a3150"/><rect x="180" y="126" width="200" height="8" rx="2" fill="#4fffb0"/><text x="670" y="128" fill="#f1f3ff">8:00 → 3:20</text>
<text x="20" y="162">packages/db</text><rect x="180" y="150" width="480" height="8" rx="2" fill="#2a3150"/><rect x="180" y="160" width="220" height="8" rx="2" fill="#4fffb0"/><text x="670" y="162" fill="#f1f3ff">8:00 → 3:40</text>
<text x="20" y="196">Lockfile-Bump</text><rect x="180" y="184" width="480" height="8" rx="2" fill="#2a3150"/><rect x="180" y="194" width="360" height="8" rx="2" fill="#ffd166"/><text x="670" y="196" fill="#f1f3ff">8:00 → 6:00 · korrekt</text>
</g>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">grau: vorher, jeder Commit · grün: nachher · gelb: der Fall, der alles neu bauen soll, und es tut</text>
</g>
</svg>
</div>

## Die Falle: eine betroffene App, die nicht gebaut wurde

Zwei Monate später lieferte ein Deploy `admin` ohne eine Änderung aus, die `admin` brauchte. Die Änderung lag in `packages/types`, einem generierten API-Client, und `admin` hängt von `types` ab, also hätte Turborepo sie markieren müssen. Tat es nicht, weil der generierte Client von einem Skript erzeugt wurde, das *außerhalb* des Turborepo-Task-Graphen lief und Dateien nach `packages/types/generated/` schrieb, das in `.gitignore` stand. Turborepo hasht getrackte Inputs. Der generierte Output war nicht getrackt, also hatte sich `types` aus seiner Sicht nicht geändert.

Die Lösung war, die Generierung zu einem Turborepo-Task mit deklarierten Outputs zu machen, sodass der Hash die Inputs des Generators (die OpenAPI-Spezifikation) enthält und der Output wie jeder andere gecacht und wiederhergestellt wird. Die Lektion verallgemeinert sich: Alles, was Build-Inputs erzeugt, muss im Graphen sein, sonst lügt der Graph. Wir haben jedes Skript in `package.json` auf dasselbe Muster geprüft und noch eines gefunden.

## Wann trotzdem alles neu gebaut wird

- Lockfile oder Root-Konfig geändert. Das macht Turborepo von selbst; nicht überschreiben.
- Ein Basis-Image-Bump in den Dockerfiles. Das Dockerfile ist ein Input für den Docker-Cache, nicht für Turborepo, also greppen wir den Diff nach `Dockerfile` und erzwingen alle drei.
- Ein Release-Tag. Jedes getaggte Release baut alle drei von Grund auf, ohne Caches, sodass das Artefakt einer Version aus dem Quellcode reproduzierbar ist und nicht aus dem, was an dem Tag zufällig im Cache lag. Das dauert acht Minuten, einmal die Woche, und es ist der Build, den wir wollen würden, falls wir je genau erklären müssten, was ausgeliefert wurde.

## Die Kurzfassung

Das Werkzeug, das den Abhängigkeitsgraphen bereits kennt, entscheiden lassen, was gebaut wird. Den Task-Cache dorthin legen, wo jeder Builder ihn erreicht. Die teure Docker-Schicht in der Registry cachen. Jeden Generator im Graphen halten. Bei Lockfile-Änderungen und Release-Tags absichtlich alles neu bauen. Acht Minuten auf drei, für einen Tag Einrichtung und $3 im Monat.

Wenn Ihr Monorepo bei jedem Push alles baut, [können wir das an einem Tag verdrahten](/contact); der Turborepo-Teil ist eine Stunde, der Docker-Cache die anderen sieben.
