# Un monorepo avec trois applications : comment nous ne construisons que ce qui a changé, et avons fait passer le pipeline de huit minutes à trois

Trois applications Next.js, quatre paquets partagés, un seul dépôt. C'est la forme de la plateforme que nous exploitons pour un client américain, et c'est la bonne forme : des types partagés, un seul lockfile, une seule pull request pour un changement qui touche l'API et les deux front-ends qui la consomment. La mauvaise partie, c'était le pipeline, qui reconstruisait les trois applications à chaque commit, y compris un commit qui changeait un README.

Huit minutes par push. Trois jobs CodeBuild, trois builds Docker, trois envois d'images, que quelque chose ait changé dans ces applications ou non. Voici comment nous l'avons ramené à trois minutes sur un commit typique et à moins d'une sur un commit de documentation seule, avec l'outillage qui l'a permis, le piège dans lequel nous sommes tombés, et le seul cas où tout reconstruire est correct.

## L'organisation

```
.
├── apps/
│   ├── web/          # application Next.js publique
│   ├── admin/        # application Next.js interne
│   └── api/          # route handlers Next.js, déployés comme service à part
├── packages/
│   ├── db/           # schéma, migrations, helpers de requêtes
│   ├── ui/           # composants partagés
│   ├── config/       # presets eslint, tsconfig, tailwind
│   └── types/        # types TypeScript partagés, client d'API généré
├── infra/            # CDK
├── pnpm-workspace.yaml
├── turbo.json
└── pnpm-lock.yaml
```

pnpm workspaces pour le graphe de paquets, Turborepo pour le graphe de tâches. Chaque application dépend d'un sous-ensemble des paquets ; `web` et `admin` dépendent de `ui`, les trois dépendent de `db` et `types`, tout dépend de `config`. Ce graphe de dépendances est toute l'entrée de « qu'est-ce qui a changé ».

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Graphe de dépendances. Les applications web, admin et api en haut. Les paquets ui, db, types et config en dessous. web et admin dépendent de ui ; les trois applications dépendent de db et types ; tout dépend de config. Un changement dans ui marque web et admin comme affectés, pas api. Un changement dans db marque les trois. Un changement dans un README ne marque rien.">
<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">changer packages/ui → construire web + admin · changer packages/db → construire les trois · changer README → ne rien construire</text>
</g>
</svg>
</div>

## Étape 1 : laisser Turborepo décider ce qui est affecté

Turborepo connaît déjà le graphe. La syntaxe `--filter` avec une plage git lui demande quels espaces de travail ont changé, directement ou via une dépendance, depuis un commit :

```bash
# quelles applications ont besoin d'un build pour ce push ?
pnpm turbo ls --affected --filter='./apps/*' --output=json | jq -r '.packages.items[].name'
```

`--affected` compare l'arbre de travail à `origin/main` par défaut (configurable avec `TURBO_SCM_BASE`), parcourt le graphe de dépendances et renvoie les applications qui dépendent transitivement de quelque chose qui a changé. Un changement dans `packages/ui` renvoie `web` et `admin`. Un changement dans `apps/api/app/orders/route.ts` renvoie `api`. Un changement dans `README.md` ne renvoie rien.

Le script de déploiement lit cette liste et démarre un job CodeBuild par application qu'elle contient, en parallèle, au lieu d'en démarrer toujours trois. C'est tout le changement du flux de contrôle du pipeline : dix lignes de shell qui remplacent une liste codée en dur de trois.

Deux choses que Turborepo compte, à raison, comme « affecte tout » : le lockfile et la configuration racine. Une montée de dépendance dans `pnpm-lock.yaml` reconstruit les trois applications, parce que n'importe laquelle a pu récupérer la nouvelle version. De même pour un changement dans `turbo.json` ou le `package.json` racine. Nous avons essayé une fois d'exclure le lockfile pour accélérer une montée de routine et avons livré une application avec une dépendance périmée. Ne le faites pas.

## Étape 2 : mettre en cache les sorties de build, à distance

À l'intérieur du build de chaque application, Turborepo met en cache les sorties de tâches indexées par un hash des entrées : fichiers sources, dépendances, variables d'environnement que vous déclarez. Un second build avec les mêmes entrées restaure la sortie depuis le cache au lieu d'exécuter `next build`. En local, c'est automatique. En CI, chaque job CodeBuild part d'un disque vide, donc le cache doit vivre quelque part de partagé.

Nous faisons tourner un petit cache distant auto-hébergé, une implémentation open source de l'API de cache distant de Turborepo, sur une `t4g.nano` qui écrit dans S3. Chaque job CodeBuild et chaque machine de développeur pointe dessus :

```json
// .turbo/config.json, ou via env en CI
{ "teamid": "team_platform", "apiurl": "https://turbo-cache.internal" }
```

```bash
# dans le buildspec
export TURBO_TOKEN=$TURBO_CACHE_TOKEN TURBO_TEAM=team_platform TURBO_API=https://turbo-cache.internal
pnpm turbo build --filter=web
```

L'effet : `packages/types` et `packages/db` sont construits une fois, par le job qui y arrive en premier, et tous les autres jobs les restaurent en une seconde. Un build de `web` qui a changé une page restaure les paquets partagés depuis le cache et n'exécute `next build` que pour `web` lui-même. Le cache distant coûte 3 $ par mois pour l'instance et quelques centimes pour S3.

## Étape 3 : mettre aussi en cache les couches Docker

Le build Docker est l'autre moitié du temps. Une [image Node multi-étapes](/fr/blog/docker-images-for-node-in-2026-distroless-multi-stage) a une couche `pnpm install` coûteuse qui ne change que lorsque le lockfile change, et une couche `next build` qui change à chaque commit. BuildKit peut exporter le cache de couches vers un registre et l'importer au build suivant :

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

Avec le cache chaud, la couche `pnpm install` est restaurée en dix secondes au lieu de tourner quatre-vingt-dix. La couche `next build` s'exécute toujours, mais le cache Turborepo à l'intérieur fait qu'elle est surtout restaurée aussi. `mode=max` met en cache les étapes intermédiaires, ce qui rend l'étape de builder réutilisable ; l'image de cache vit dans ECR sous un tag fixe et coûte quelques centaines de mégaoctets de stockage.

## Les chiffres

| Type de commit | Avant | Après | Ce qui s'exécute |
|---|---|---|---|
| Documentation, infra, config CI seulement | 8 min | 40 s | Turborepo dit que rien n'est affecté ; le script de déploiement saute tous les builds ; diff CDK seulement |
| Une page dans `apps/web` | 8 min | 2 min 50 s | Un job CodeBuild ; paquets partagés depuis le cache distant ; couche Docker d'installation depuis le cache de registre |
| Changement dans `packages/ui` | 8 min | 3 min 20 s | Deux jobs en parallèle, `web` et `admin` ; `api` intact |
| Changement dans `packages/db` | 8 min | 3 min 40 s | Trois jobs en parallèle ; les caches fonctionnent encore pour les couches d'installation |
| Montée de lockfile | 8 min | 6 min | Trois jobs ; couche d'installation reconstruite partout ; cache Turborepo invalidé ; correct |

Le commit médian est la deuxième ligne. De huit minutes à moins de trois, et, parce que les jobs qui s'exécutent sont en parallèle, le *maximum* est maintenant le cas du lockfile à six, pas les anciennes huit à chaque commit.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Graphique à barres de la durée du pipeline par type de commit, avant et après. Avant : 8 minutes pour chaque type. Après : 40 secondes pour la documentation seule, 2 minutes 50 pour un changement d'application unique, 3 minutes 20 pour un changement du paquet ui, 3 minutes 40 pour un changement du paquet db, 6 minutes pour une montée de lockfile.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Durée du pipeline par type de commit</text>
<g fill="#9aa3c7">
<text x="20" y="60">docs seulement</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">une application</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">montée de lockfile</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 · correct</text>
</g>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">gris : avant, chaque commit · vert : après · jaune : le cas qui doit tout reconstruire, et le fait</text>
</g>
</svg>
</div>

## Le piège : une application affectée qui n'a pas été construite

Deux mois plus tard, un déploiement a livré `admin` sans un changement dont `admin` avait besoin. Le changement était dans `packages/types`, un client d'API généré, et `admin` dépend de `types`, donc Turborepo aurait dû le signaler. Il ne l'a pas fait, parce que le client généré était produit par un script qui tournait *en dehors* du graphe de tâches Turborepo, écrivant des fichiers dans `packages/types/generated/`, qui était dans `.gitignore`. Turborepo hache les entrées suivies. La sortie générée n'était pas suivie, donc de son point de vue `types` n'avait pas changé.

Le correctif a été de faire de la génération une tâche Turborepo avec des sorties déclarées, pour que le hash inclue les entrées du générateur (la spécification OpenAPI) et que la sortie soit mise en cache et restaurée comme n'importe quelle autre. La leçon se généralise : tout ce qui produit des entrées de build doit être à l'intérieur du graphe, sinon le graphe vous ment. Nous avons audité chaque script de `package.json` à la recherche du même schéma et en avons trouvé un de plus.

## Quand tout reconstruire quand même

- Le lockfile ou la configuration racine a changé. Turborepo le fait tout seul ; ne l'outrepassez pas.
- Une montée d'image de base dans les Dockerfiles. Le Dockerfile est une entrée du cache Docker, pas de Turborepo, donc nous greppons le diff à la recherche de `Dockerfile` et forçons les trois.
- Un tag de release. Chaque release taguée reconstruit les trois depuis zéro, sans caches, pour que l'artefact d'une version soit reproductible depuis les sources et non depuis ce qui se trouvait dans le cache ce jour-là. Ça prend huit minutes, une fois par semaine, et c'est le build que nous voudrions s'il fallait un jour expliquer exactement ce qui a été livré.

## La version courte

Laissez l'outil qui connaît déjà le graphe de dépendances décider ce qu'il faut construire. Mettez le cache de tâches quelque part où chaque builder peut l'atteindre. Mettez en cache la couche Docker coûteuse dans le registre. Gardez chaque générateur à l'intérieur du graphe. Reconstruisez tout sur les changements de lockfile et sur les tags de release, volontairement. De huit minutes à trois, pour une journée de mise en place et 3 $ par mois.

Si votre monorepo construit tout à chaque push, [nous pouvons câbler ça en une journée](/contact) ; la partie Turborepo prend une heure, le cache Docker les sept autres.
