GitLab CI for a Turborepo + Next.js monorepo
TL;DR
Monorepo CI got fast when we stopped treating it as one shared queue and one black-box Docker build.
Three ideas carried most of the weight:
- Layered host caching under
/srv/cache - No duplicated heavy work inside a single pipeline
- Controlled concurrency — CPU, RAM, Turbo, Next, and GitLab jobs as knobs
Measured wins on a warm machine: pnpm install ~22s, full Turbo hit ~1s, static upload ~2m30s → ~1m30s, and only one live pipeline per branch.
This post is a living note. The principles stay; numbers, toolchain versions, and recipes get updated as the setup evolves. See the changelog at the end.
Context
Our frontend monorepo shared a CI server with every other frontend project. Queue times drifted up. Causes piled up, but the systemic fix was simple: split the load.
DevOps gave us a dedicated monorepo CI box — 16 CPUs, 32 GB RAM. We treated the migration as a clean experiment: throwaway mini-monorepo first (two apps + one shared package, same stack), tune caches there, then roll the winning shape into the real pipeline.
Everything below hangs off one host directory mounted into job containers:
/srv/cache
├── pnpm-store/ # global — all branches
├── turbo/ # global — all branches
├── nextjs/$BRANCH/ # per branch
└── tsbuildinfo/$BRANCH/ # per branch
Layer 1: Caching
1. pnpm — global store, warmed before build
Problem: Pipelines behaved like they were downloading dependencies from scratch.
What we changed: Global store at /srv/cache/pnpm-store.
pnpm install is really two steps:
pnpm fetch— fill the store- Link
node_modulesfrom the store (hardlinks)
An install stage before build warms the store with pnpm fetch. Every branch reuses the same store. Warm runs show downloaded 0, reused all.
Result: Warm install ~22s.
2. Turborepo — global cache, content-hash safe
Problem: Turbo output was not sticking across pipelines the way we needed.
What we changed: Global cache at /srv/cache/turbo.
Turbo is content-hash based, so a global cache is safe across branches. Same inputs for packages/ui in branch A → reuse in branch B.
- Shared package changes → dependents rebuild
- One app changes → only that app rebuilds
- Nothing changes → FULL TURBO (~1s)
TURBO_CONCURRENCY caps parallel tasks (we use 2 on build runners).
3. Next.js — per-branch cache, controlled CPU
Problem: Next build cache did not share cleanly, and builds grabbed an uncontrolled number of cores (os.cpus() - 1 by default).
What we changed: Per-branch cache at /srv/cache/nextjs/$BRANCH, plus:
NEXT_BUILD_CPUS=1
Unlike Turbo, Next's on-disk build cache is not a safe global content-hash store — keep it per branch. Rebuilds on the same branch still speed up. Concurrency belongs at Turbo / GitLab job level, not inside every Next process.
4. TypeScript — incremental tsbuildinfo per branch
Problem: check-types recompiled from scratch too often.
What we changed: Per-branch incremental cache at /srv/cache/tsbuildinfo/$BRANCH.
tsc reuses prior tsbuildinfo. This is the cheap path for frequent typecheck jobs — and it stacks with later compiler upgrades (see changelog).
Layer 2: Pipeline flow
5. interruptible: true — one pipeline per branch
Problem: A new push did not cancel the previous pipeline. Both queued. That alone explained a lot of "CI is stuck".
What we changed: interruptible: true on jobs.
Result: Only the latest commit's pipeline runs per branch.
6. Build and package are separate stages
Problem: Job containers reused host caches early in the pipeline, but the Docker/BuildKit path could not mount /srv/cache the same way. Dockerfiles ran pnpm install + turbo build again — duplicating the heavy work in one pipeline.
What we changed:
- Build — compile apps, upload artifacts
- Package — thin Docker images from those artifacts, push to the registry
Dockerfiles copy prebuilt output and set an entrypoint. No second install. No second turbo build.
7. One monorepo Docker image, per-app entrypoints
Problem: One image per app — seven maintenance paths and a lot of registry disk.
What we changed: One monorepo image (~1 GB). App selection = entrypoint.
Result: Roughly ~1 GB × 1 vs hundreds of GB across seven images, with room to shrink further.
8. Parallel static uploads and tighter job graph
Problem: push-s3 uploaded sequentially and waited on more of the pipeline than it needed.
What we changed: Parallel uploads; push-s3 depends on build only (not the whole package stage); minor app dependency cleanup.
Result: ~2m30s → ~1m30s, and downstream work starts earlier.
Layer 3: Resource control
9. Bounded Node.js memory
Problem: Heap limits were defaults — unpredictable across pnpm / Turbo / Next / tsc.
What we changed:
NODE_OPTIONS=--max-old-space-size=2048
2 GB is an explicit budget, overrideable per job — not "use all RAM" and not "hope the default is fine".
10. Two GitLab runners: light and build
Problem: Fast jobs sat behind slow builds (and the reverse).
What we changed: Two runners on the same machine:
| Runner | CPU | RAM | Concurrency | Role |
|---|---|---|---|---|
light | 1 | 2 GB | 6 | install, lint, check-types, thin package jobs |
build | 2 | 4 GB | 3 | turbo build, next build |
Light work does not wait on builds. Builds get dedicated slots. Exact tags and which side owns package can move — the split is the idea.
11. Load-tested concurrency
After CPU and memory were knobs, we load-tested cold / warm / partial caches on the 16 CPU / 32 GB box.
| Runner | TURBO_CONCURRENCY | NEXT_BUILD_CPUS |
|---|---|---|
build | 2 | 1 |
light | 1 | 1 |
Not universal. Measure on your machine.
12. build-per-app (for now)
Problem: One giant serial build vs many parallel app jobs.
What we changed: Tried build-all and build-per-app; kept build-per-app.
Result: Better fit for the current runner layout. Turbopack-era build-all stays an open experiment.
Shape of the pipeline (starter)
Enough to recreate the idea — not a full dump of our private config. Grow this as needed.
# sketch — not a complete .gitlab-ci.yml
default:
interruptible: true
stages:
- install
- build
- package
# install: pnpm fetch into /srv/cache/pnpm-store (light runner)
# build: turbo/next into host caches + job artifacts (build runner)
# package: thin Docker from artifacts (light or dedicated)
# push-s3: needs: ["build"] — do not wait on package
Host mounts (Docker executor sketch):
# runner config sketch
[[runners.docker.volumes]]
# map host caches into the job
# "/srv/cache:/srv/cache:rw"
Env knobs we actually use are in the settings summary.
Settings summary
| Setting / path | Scope | Value (ours) | Why |
|---|---|---|---|
/srv/cache/pnpm-store | Global | Host mount | Reuse downloads across branches |
/srv/cache/turbo | Global | Host mount | Content-hash safe across branches |
/srv/cache/nextjs/$BRANCH | Per branch | Host mount | Next cache not safe globally |
/srv/cache/tsbuildinfo/$BRANCH | Per branch | Host mount | Incremental check-types |
TURBO_CONCURRENCY | Per runner | 2 / 1 | Cap Turbo tasks |
NEXT_BUILD_CPUS | Global | 1 | Stop Next from grabbing all cores |
NODE_OPTIONS | Global | --max-old-space-size=2048 | Explicit Node heap budget |
interruptible | Per job | true | Cancel stale pipelines |
| Build / package split | Pipeline | Separate stages | No duplicate install+build in Docker |
| Docker image | Registry | One image, per-app entrypoints | Less storage, simpler maintenance |
push-s3 | Job graph | Parallel; needs: [build] | Faster static deploy |
Takeaways
- Global cache only where the model is content-hash safe — pnpm + Turbo yes; Next +
tsbuildinfoper branch. - Reuse cache on every stage — if Docker/BuildKit cannot see the host store, you will rebuild twice.
- Do heavy work once — build produces artifacts; package only wraps them.
- Make resources knobs — memory, CPU per tool, separate light/build runners.
- Cancel stale work —
interruptible: trueis easy to miss. - Stack toolchain wins on CI wins — faster
tscstill hurts if the pipeline installs and builds twice.
Monorepo CI slowness is usually several small leaks: duplicated work, uncancelled pipelines, caches that reset between stages, processes fighting for cores. Plug the leaks; keep updating the note as the stack moves.
Changelog
- 2026-07-06 — Initial write-up: dedicated runners,
/srv/cachelayers, build/package split, concurrency knobs, single monorepo image, parallelpush-s3. - 2026-07 — Toolchain follow-up: Next.js 16.3 + TypeScript 7 (local arm64, no Rosetta).
next buildis not one phase — Compile (Turbopack) vs TypeScript inside the build, plus standalonetsc/check-types.
| App | Metric | Next 16.2 + TS 6 | Next 16.3 + TS 7 | Δ |
|---|---|---|---|---|
| App A | tsc | 4.4s | 1.8s | −59% |
| App A | Compile | 20.9s | 23.2s | +11% |
| App A | TypeScript (in next build) | 21.5s | 2.8s | −87% |
| App A | Full next build | 48.4s | 32.0s | −34% |
| App B | tsc | 4.4s | 1.3s | −71% |
| App B | Compile | 20.1s | 21.2s | +5% |
| App B | TypeScript (in next build) | 23.2s | 2.7s | −88% |
| App B | Full next build | 46.9s | 28.0s | −40% |
TypeScript phase is the real win. Compile slightly regressed on 16.3 and is still dominated by the TS cut. Full builds landed ~34–40% faster. That stacks with the per-branch tsbuildinfo cache above.
Open threads for later edits: revisit build-all under Turbopack, shrink the monorepo image further, publish fuller runner config.toml / .gitlab-ci.yml excerpts if they stay stable enough to share.