On this page
A monorepo is supposed to make changes easier: one pull request, one review, one version of every shared package. In practice, many teams find their CI grows until every pull request takes 20 to 30 minutes, regardless of whether it touched one line of CSS or the core data model.
We recently rebuilt the GitHub Actions setup for a Series A healthtech company with a pnpm and Turborepo monorepo: four Next.js and Node apps, eleven shared packages and about 30 engineers. Median CI time went from 24 minutes to 4 minutes 10 seconds. This post covers the workflow we ended up with, why each piece is there, and what to watch for if you copy it.
Where the 24 minutes went
Before changing anything, we pulled timing data for the last 200 runs using the GitHub CLI and looked at where time was spent.
gh run list --workflow ci.yml --limit 200 --json databaseId,conclusion,createdAt,updatedAt \
--jq '.[] | select(.conclusion=="success") | [.databaseId, .createdAt, .updatedAt] | @tsv'Per-job timings came from gh run view <id> --json jobs. The breakdown looked like this:
| Step | Before | After | Change |
|---|---|---|---|
| Checkout and install | 3m 40s | 0m 45s | pnpm store cache, shallow fetch |
| Lint and typecheck | 5m 10s | 1m 05s | Affected packages only, Turborepo cache |
| Unit tests | 8m 30s | 1m 50s | Affected only, sharded across 4 runners |
| Build | 4m 20s | 1m 10s | Remote cache hits on unchanged packages |
| Docker image | 2m 30s | 0m 40s | BuildKit layer cache in GitHub Actions cache |
| Wall clock (median) | 24m | 4m 10s | Parallel jobs, less work per job |
Two things stood out. Everything ran for every change, and nothing was cached between runs except node_modules, and that cache was keyed badly enough to miss half the time.
Principle one: only do work for what changed
The biggest win in any monorepo is not running tasks for packages that a change cannot affect. There are two common ways to do this in GitHub Actions.
Path filters versus the task graph
Path filters (the paths: key on the trigger, or an action like dorny/paths-filter) are easy to set up but know nothing about dependencies. If packages/ui changes, path filters will not know that apps/web and apps/admin depend on it unless you maintain that mapping by hand.
The build tool already knows the dependency graph. Turborepo 2 supports --affected, which compares the current commit to the base branch and runs tasks only for changed packages and their dependents. Nx has the equivalent nx affected. We used Turborepo because the client already did.
--affected needs git history back to the merge base. A default actions/checkout fetches a single commit, so you either set fetch-depth: 0 or fetch just enough. On a large repository, a full clone can take close to a minute on its own, so we keep full history but skip file contents with a partial clone:
- uses: actions/checkout@v4
with:
fetch-depth: 0
filter: blob:noneA blobless clone fetches every commit and tree but downloads file contents only when needed. On this repository it took 6 seconds, against 48 for a full clone.
Principle two: cache everything that is deterministic
Dependency install
actions/setup-node can cache the pnpm store, keyed on the lockfile. Combined with pnpm install --frozen-lockfile, installs drop to 15 to 20 seconds on a warm cache.
Task outputs
Turborepo hashes each task's inputs (source files, dependencies, environment variables listed in turbo.json) and can replay outputs when the hash matches. Locally this lives in .turbo. In CI you want a remote cache so that every runner and every developer shares results. Vercel's hosted remote cache works with a TURBO_TOKEN and TURBO_TEAM; there are also self-hosted options that implement the same API on top of S3.
The important detail is declaring inputs correctly. If a task reads an environment variable that is not declared in turbo.json, you will get stale cache hits. We set envMode to strict (the default in Turborepo 2) and listed every variable each task uses.
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"env": ["NEXT_PUBLIC_API_URL", "SENTRY_RELEASE"]
},
"lint": {},
"typecheck": { "dependsOn": ["^build"] },
"test": { "outputs": ["coverage/**"] }
}
}The workflow
Here is the core of the workflow, lightly trimmed. It has a setup job that decides what changed, parallel jobs for checks and sharded tests, and a final gate job.
name: ci
on:
pull_request:
merge_group:
push:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
checks:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0, filter: 'blob:none' }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint typecheck build --affected
test:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0, filter: 'blob:none' }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run test --affected -- --shard=${{ matrix.shard }}/4
ci-ok:
if: always()
needs: [checks, test]
runs-on: ubuntu-latest
steps:
- run: |
if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
exit 1
fiA few details are worth calling out.
Concurrency. When someone pushes a second commit to a pull request, the first run is cancelled. We only cancel for pull requests; runs on main and in the merge queue always complete, because a cancelled main run leaves you without a signal on what shipped.
Test sharding. Vitest and Playwright both support --shard=index/total. Passing it through Turborepo after -- forwards it to each package's test script. For small packages, sharding adds overhead, so we only shard the two apps with large suites; the rest run in one shard. Sharding four ways cut the largest app's test time from about 7 minutes to under 2.
The gate job. When jobs are skipped because nothing relevant changed, required status checks become awkward: a skipped job may never report, and the pull request waits forever. Instead of making every job required, we make only ci-ok required in branch protection. It runs with if: always() and fails if any dependency failed or was cancelled. Skipped counts as fine.
Docker images without rebuilding the world
The client built one image per deployable app on merge to main. The old workflow ran docker build with no cache, so every build reinstalled dependencies from scratch.
We switched to docker/build-push-action with the GitHub Actions cache backend, and used turbo prune in the Dockerfile so each image only contains the packages its app needs.
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: apps/api/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/api:${{ github.sha }}
cache-from: type=gha,scope=api
cache-to: type=gha,scope=api,mode=maxThe scope keeps each app's cache separate, so they do not evict each other. The GitHub Actions cache is limited per repository (10 GB by default, with paid expansion available), and least-recently-used entries are evicted when you exceed it. If you build many large images, a registry cache (type=registry) avoids that limit.
Runners and the merge queue
We tested larger GitHub-hosted runners (8 and 16 cores). For this repository, 4-core runners were the sweet spot: type checking the largest app is single-threaded, and the extra cores sat idle. Measure before paying for bigger machines; parallel jobs on standard runners often beat one large runner.
We also enabled the merge queue on main. With 30 engineers merging throughout the day, pull requests often passed CI against an outdated base and then broke main. The merge queue runs CI on the combined result before merging. That is why the workflow listens to the merge_group event; forget it and the queue will wait for checks that never start.
What we'd do differently
- Instrument first. We spent the first day guessing. The timing breakdown from
gh run viewshould have been the first thing we did. - Audit
turbo.jsoninputs before enabling the remote cache. We had one incident where a build reused output compiled with a staleNEXT_PUBLIC_variable because it was not listed underenv. Strict env mode caught the rest. - Keep one required check. Making individual jobs required caused a week of stuck pull requests before we moved to the gate job pattern.
- Watch the cache size. Docker layer caches filled the 10 GB limit within a fortnight and started evicting the pnpm cache. Separate scopes and a registry cache for the largest images fixed it.
Checklist
- Measure per-step timings across your last 100 to 200 runs.
- Use the task graph (
turbo --affectedornx affected), not path filters, to decide what runs. - Use a blobless clone with full history so affected detection works quickly.
- Cache the package manager store keyed on the lockfile.
- Turn on a remote task cache and declare every environment variable each task reads.
- Shard large test suites; leave small ones alone.
- Cancel superseded pull request runs, never
mainruns. - Make a single
if: always()gate job the only required check. - Use BuildKit with a scoped cache for Docker images.
- Add
merge_groupto your triggers before enabling the merge queue.
Four minutes is not a magic number, but it is roughly the point where engineers stop switching context while they wait. Past that, every extra minute of CI costs more than the runner bill suggests.



