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".
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:
# 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:
// .turbo/config.json, or via env in CI
{ "teamid": "team_platform", "apiurl": "https://turbo-cache.internal" }
# 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 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:
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.
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
Dockerfileand 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; the Turborepo part is an hour, the Docker cache is the other seven.