# A monorepo with three apps: how we build only what changed, and got the pipeline from eight minutes to three

Three Next.js applications, four shared packages, one repository. That's the shape of the platform we run for a US client, and it's the right shape: shared types, one lockfile, one pull request for a change that touches the API and the two front-ends that consume it. The wrong part was the pipeline, which rebuilt all three applications on every commit, including a commit that changed a README.

Eight minutes per push. Three CodeBuild jobs, three Docker builds, three image pushes, whether or not anything in those applications had changed. This is how we got it to three minutes on a typical commit and under one on a docs-only commit, with the tooling that did it, the trap we fell into, and the one case where rebuilding everything is correct.

## The layout

```
.
├── apps/
│   ├── web/          # public Next.js app
│   ├── admin/        # internal Next.js app
│   └── api/          # Next.js route handlers, deployed as its own service
├── packages/
│   ├── db/           # schema, migrations, query helpers
│   ├── ui/           # shared components
│   ├── config/       # eslint, tsconfig, tailwind presets
│   └── types/        # shared TypeScript types, generated API client
├── infra/            # CDK
├── pnpm-workspace.yaml
├── turbo.json
└── pnpm-lock.yaml
```

pnpm workspaces for the package graph, Turborepo for the task graph. Each app depends on some subset of the packages; `web` and `admin` depend on `ui`, all three depend on `db` and `types`, everything depends on `config`. That dependency graph is the whole input to "what changed".

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Dependency graph. Apps web, admin and api at the top. Packages ui, db, types and config below. web and admin depend on ui; all three apps depend on db and types; everything depends on config. A change to ui marks web and admin affected, not api. A change to db marks all three. A change to a README marks nothing.">
<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">change packages/ui → build web + admin · change packages/db → build all three · change README → build nothing</text>
</g>
</svg>
</div>

## Step 1: let Turborepo decide what's affected

Turborepo already knows the graph. The `--filter` syntax with a git range asks it which workspaces have changed, directly or through a dependency, since a commit:

```bash
# which apps need a build for this push?
pnpm turbo ls --affected --filter='./apps/*' --output=json | jq -r '.packages.items[].name'
```

`--affected` compares the working tree against `origin/main` by default (configurable with `TURBO_SCM_BASE`), walks the dependency graph, and returns the apps that transitively depend on something that changed. A change to `packages/ui` returns `web` and `admin`. A change to `apps/api/app/orders/route.ts` returns `api`. A change to `README.md` returns nothing.

The deploy script reads that list and starts a CodeBuild job per app in it, in parallel, instead of always starting three. That's the entire change to the pipeline's control flow: ten lines of shell replacing a hard-coded list of three.

Two things Turborepo counts as "affecting everything", correctly: the lockfile and the root config. A dependency bump in `pnpm-lock.yaml` rebuilds all three apps, because any of them might have picked up the new version. So does a change to `turbo.json` or the root `package.json`. We tried excluding the lockfile once to speed up a routine bump and shipped an app with a stale dependency. Don't.

## Step 2: cache the build outputs, remotely

Inside each app's build, Turborepo caches task outputs keyed by a hash of the inputs: source files, dependencies, environment variables you declare. A second build with the same inputs restores the output from cache instead of running `next build`. Locally that's automatic. In CI, each CodeBuild job starts from an empty disk, so the cache has to live somewhere shared.

We run a small self-hosted remote cache, an open-source implementation of Turborepo's remote cache API, on a `t4g.nano` writing to S3. Every CodeBuild job and every developer machine points at it:

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

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

The effect: `packages/types` and `packages/db` are built once, by whichever job gets there first, and every other job restores them in a second. A `web` build that changed one page restores the shared packages from cache and only runs `next build` for `web` itself. The remote cache costs $3 a month for the instance and pennies for S3.

## Step 3: cache the Docker layers too

The Docker build is the other half of the time. A [multi-stage Node image](/en/blog/docker-images-for-node-in-2026-distroless-multi-stage) has an expensive `pnpm install` layer that only changes when the lockfile does, and a `next build` layer that changes every commit. BuildKit can export the layer cache to a registry and import it on the next build:

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

With the cache warm, the `pnpm install` layer is restored in ten seconds instead of running for ninety. The `next build` layer still runs, but Turborepo's cache inside it means it mostly restores too. `mode=max` caches intermediate stages, which is what makes the builder stage reusable; the cache image lives in ECR under a fixed tag and costs a few hundred megabytes of storage.

## The numbers

| Commit type | Before | After | What runs |
|---|---|---|---|
| Docs, infra, CI config only | 8 min | 40 s | Turborepo says nothing affected; deploy script skips all builds; CDK diff only |
| One page in `apps/web` | 8 min | 2 min 50 s | One CodeBuild job; shared packages from remote cache; Docker install layer from registry cache |
| `packages/ui` change | 8 min | 3 min 20 s | Two jobs in parallel, `web` and `admin`; `api` untouched |
| `packages/db` change | 8 min | 3 min 40 s | Three jobs in parallel; caches still hit for install layers |
| Lockfile bump | 8 min | 6 min | Three jobs; install layer rebuilt everywhere; Turborepo cache invalidated; correct |

The median commit is the second row. Eight minutes to under three, and, because the jobs that do run are in parallel, the *maximum* is now the lockfile case at six, not the old every-commit eight.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Bar chart of pipeline duration by commit type, before and after. Before: 8 minutes for every type. After: 40 seconds for docs-only, 2 minutes 50 for a single app change, 3 minutes 20 for a ui package change, 3 minutes 40 for a db package change, 6 minutes for a 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 duration by commit type</text>
<g fill="#9aa3c7">
<text x="20" y="60">docs only</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">one 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 · correct</text>
</g>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">grey: before, every commit · green: after · yellow: the case that should rebuild everything, and does</text>
</g>
</svg>
</div>

## The trap: an affected app that wasn't built

Two months in, a deploy shipped `admin` without a change that `admin` needed. The change was in `packages/types`, a generated API client, and `admin` depends on `types`, so Turborepo should have flagged it. It didn't, because the generated client was produced by a script that ran *outside* the Turborepo task graph, writing files into `packages/types/generated/`, which was in `.gitignore`. Turborepo hashes tracked inputs. The generated output wasn't tracked, so from its point of view `types` hadn't changed.

The fix was to make generation a Turborepo task with declared outputs, so the hash includes the generator's inputs (the OpenAPI spec) and the output is cached and restored like any other. The lesson generalises: anything that produces build inputs has to be inside the graph, or the graph is lying to you. We audited every script in `package.json` for the same pattern and found one more.

## When to rebuild everything anyway

- Lockfile or root config changed. Turborepo does this on its own; don't override it.
- A base image bump in the Dockerfiles. The Dockerfile is an input to the Docker cache, not to Turborepo, so we grep the diff for `Dockerfile` and force all three.
- A release tag. Every tagged release rebuilds all three from scratch, no caches, so the artifact for a version is reproducible from source and not from whatever happened to be in the cache that day. It takes eight minutes, once a week, and it's the build we'd want if we ever had to explain exactly what shipped.

## The short version

Let the tool that already knows the dependency graph decide what to build. Put the task cache somewhere every builder can reach. Cache the expensive Docker layer in the registry. Keep every generator inside the graph. Rebuild everything on lockfile changes and on release tags, on purpose. Eight minutes to three, for a day of setup and $3 a month.

If your monorepo builds everything on every push, [we can wire this up in a day](/contact); the Turborepo part is an hour, the Docker cache is the other seven.
