Hoe verschep je een pnpm-monorepo naar Docker zonder een build van 30 minuten
De Docker-build-strategie voor een pnpm-monorepo die cold builds onder vijf minuten houdt en incrementele builds onder dertig seconden. De workspace-bewuste Dockerfile, de lockfile-pinned install en de deploy-val die ons op dag één beet.
De eerste deploy van een pnpm-monorepo naar productie is wanneer het team leert wat een "korte Dockerfile" werkelijk betekent. Een naïeve Dockerfile herbouwt de hele lockfile bij elke CI-run. We hebben zien gebeuren dat een standaard-timeout van 10 minuten van appleboy/ssh-action werd opgegeten door een cold build. Hier is het patroon waarop we zijn geland, en de val die ons op dag één beet.
De workspace-bewuste Dockerfile
Twee niet-onderhandelbare ontwerpregels voor een monorepo-Dockerfile:
- Context is de root van de monorepo. De build-context moet de workspace-root zijn, niet
apps/backendofapps/web. De pakketten verwijzen naar elkaar over de workspace; de install moet alle lockfile- en workspace-declaraties zien. - De lockfile is het contract. Dezelfde
pnpm-lock.yamldie de ontwikkelaar lokaal draaide is degene die de CI installeert. We pinnen opfrozen-lockfile=truein Docker en in CI om die reden.
Het backend-Dockerfile-patroon:
- Een builder-stage die de volledige
node:20-alpine-image gebruikt en één keerpnpm install --frozen-lockfiledraait. - Een runner-stage die alleen
apps/backend/dist,node_modulesvoor runtime, enapps/backend/package.jsonkopieert. Geen source, geen test-fixtures, geen build-tooling.
Het web-Dockerfile-patroon (afzonderlijke concerns, andere stage):
- Een builder-stage die Next.js compileert met
output: "standalone"en die standalone-output al eennode_modulesbevat met alleen de runtime-afhankelijkheden. - Een runner-stage die de standalone-output en de publieke assets kopieert, en dan
node server.jsdraait.
De deploy-val die ons op dag één beet
Onze eerste deploy-poging was de klassieke vergissing: elke app heeft zijn eigen Dockerfile, elk Compose-bestand gebruikt de build-context voor de app in kwestie, en het productie-Compose-bestand verwijst naar een andere projectnaam dan het dev-bestand. Twee processen eindigden gebonden aan dezelfde host-poort.
De fix:
- Eén build-context per app. De Dockerfile-build-context is altijd de monorepo-root. De apps gebruiken
apps/web/Dockerfile- enapps/backend/Dockerfile-bestanden, maar de context is. - Expliciete Compose-projectnaam. Productie-Compose gebruikt
-p 2runbe-monorepoom de containers te naamruimten weg van andere apps die mogelijk op dezelfde host staan. Zonder dit kunnen twee Compose-bestanden stil dezelfde poort claimen. - Het Compose external network. Productie draait de backend en web op een gedeeld
fenix-shared-services-netwerk naast andere apps die dezelfde MongoDB- en Redis-instances delen. Lokale dev draait zijn eigen compose op hetapp-netwerk met het--profile full-profiel dat zijn eigen MongoDB en Redis opstart.
Lockfile-drift-detectie
Twee manieren waarop lockfile-drift je bijt:
- Een ontwikkelaar werkt
pnpm-lock.yamlbij, de CI draait tegen de oude Dockerfile die het van een vaste locatie kopieert, en de install faalt omdat de lockfile nieuwer is dan de dependency-tree. - Een tweede ontwikkelaar draait
pnpm installlokaal zonder de frozen lockfile en krijgt een iets andere lockfile dan de CI.
Wij vergrendelen beide kanten:
- Pre-commit hook draait
pnpm install --frozen-lockfileen weigert te committen als het faalt. - CI gebruikt ook frozen lockfile. Als de ontwikkelaar de hook omzeilde, vangt de CI het vóór de deploy.
Cold vs incrementele builds
Met de workspace-bewuste Dockerfile en gepinde lockfile zijn de cold build-tijden:
- Backend. ~3 minuten op een 4-core runner. De meeste tijd is
pnpm installover de hele workspace. - Web. ~4 minuten. Next.js-builds domineren zodra
pnpm installis gecached.
De incrementele build-tijden (cache-hit op pnpm install):
- Backend. ~25 seconden voor een enkele dependency-upgrade, ~40 seconden voor een NestJS-source-wijziging.
- Web. ~30 seconden voor een enkele source-wijziging, ~90 seconden voor een dependency-upgrade.
De CI-cache-strategie die dit werkend maakte:
- Cache
pnpm storeop basis van de volledige lockfile-hash. Een wijziging in een willekeurig pakket bumpt de cache-key, maar niet-gerelateerde wijzigingen raken de cache. - Cache
node_modulesper-app op de lockfile plus de workspace-package.json. Twee apps met dezelfde lockfile delen node_modules wanneer de dependency-tree het toelaat.
De CI-command-timeout verhogen
De standaard command-timeout van appleboy/ssh-action is 10 minuten. Een backend cold build met een verse lockfile duurt ~10 minuten op een trage runner, wat precies op de faaldrempel zit. Verhoog de timeout naar 30 minuten voor elke deploy-stap die een Docker-build inhoudt. Het deploy-script dat timeoutte op 9 minuten 50 seconden is een deploy-script dat je om 23:00 moet debuggen.
