@sargonpiraev

GitLab CI for a Turborepo + Next.js monorepo

#monorepo#ci#pnpm#turborepo#nextjs#typescript#devops#gitlab

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:

  1. Layered host caching under /srv/cache
  2. No duplicated heavy work inside a single pipeline
  3. 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:

  1. pnpm fetch — fill the store
  2. Link node_modules from 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:

RunnerCPURAMConcurrencyRole
light12 GB6install, lint, check-types, thin package jobs
build24 GB3turbo 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.

RunnerTURBO_CONCURRENCYNEXT_BUILD_CPUS
build21
light11

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 / pathScopeValue (ours)Why
/srv/cache/pnpm-storeGlobalHost mountReuse downloads across branches
/srv/cache/turboGlobalHost mountContent-hash safe across branches
/srv/cache/nextjs/$BRANCHPer branchHost mountNext cache not safe globally
/srv/cache/tsbuildinfo/$BRANCHPer branchHost mountIncremental check-types
TURBO_CONCURRENCYPer runner2 / 1Cap Turbo tasks
NEXT_BUILD_CPUSGlobal1Stop Next from grabbing all cores
NODE_OPTIONSGlobal--max-old-space-size=2048Explicit Node heap budget
interruptiblePer jobtrueCancel stale pipelines
Build / package splitPipelineSeparate stagesNo duplicate install+build in Docker
Docker imageRegistryOne image, per-app entrypointsLess storage, simpler maintenance
push-s3Job graphParallel; needs: [build]Faster static deploy

Takeaways

  1. Global cache only where the model is content-hash safe — pnpm + Turbo yes; Next + tsbuildinfo per branch.
  2. Reuse cache on every stage — if Docker/BuildKit cannot see the host store, you will rebuild twice.
  3. Do heavy work once — build produces artifacts; package only wraps them.
  4. Make resources knobs — memory, CPU per tool, separate light/build runners.
  5. Cancel stale workinterruptible: true is easy to miss.
  6. Stack toolchain wins on CI wins — faster tsc still 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/cache layers, build/package split, concurrency knobs, single monorepo image, parallel push-s3.
  • 2026-07 — Toolchain follow-up: Next.js 16.3 + TypeScript 7 (local arm64, no Rosetta). next build is not one phase — Compile (Turbopack) vs TypeScript inside the build, plus standalone tsc / check-types.
AppMetricNext 16.2 + TS 6Next 16.3 + TS 7Δ
App Atsc4.4s1.8s−59%
App ACompile20.9s23.2s+11%
App ATypeScript (in next build)21.5s2.8s−87%
App AFull next build48.4s32.0s−34%
App Btsc4.4s1.3s−71%
App BCompile20.1s21.2s+5%
App BTypeScript (in next build)23.2s2.7s−88%
App BFull next build46.9s28.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.