Skip to content

A GitHub Actions setup for monorepos that finishes in under five minutes

How we took a pnpm and Turborepo monorepo from 24-minute CI runs to just over four, using affected-only tasks, remote caching, test sharding and a single gate job.

Written by

Lisa Park

Senior DevOps Engineer

Published
Reading time
7 min
Share
Image: Homa Appliances on Unsplash · Source
On this page
  1. Where the 24 minutes went
  2. Principle one: only do work for what changed
  3. Principle two: cache everything that is deterministic
  4. The workflow
  5. Docker images without rebuilding the world
  6. Runners and the merge queue
  7. What we'd do differently
  8. Checklist

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.

bash
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:

StepBeforeAfterChange
Checkout and install3m 40s0m 45spnpm store cache, shallow fetch
Lint and typecheck5m 10s1m 05sAffected packages only, Turborepo cache
Unit tests8m 30s1m 50sAffected only, sharded across 4 runners
Build4m 20s1m 10sRemote cache hits on unchanged packages
Docker image2m 30s0m 40sBuildKit layer cache in GitHub Actions cache
Wall clock (median)24m4m 10sParallel 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:

yaml
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
    filter: blob:none

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

json
{
  "$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.

yaml
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
          fi

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

yaml
- 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=max

The 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 view should have been the first thing we did.
  • Audit turbo.json inputs before enabling the remote cache. We had one incident where a build reused output compiled with a stale NEXT_PUBLIC_ variable because it was not listed under env. 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

  1. Measure per-step timings across your last 100 to 200 runs.
  2. Use the task graph (turbo --affected or nx affected), not path filters, to decide what runs.
  3. Use a blobless clone with full history so affected detection works quickly.
  4. Cache the package manager store keyed on the lockfile.
  5. Turn on a remote task cache and declare every environment variable each task reads.
  6. Shard large test suites; leave small ones alone.
  7. Cancel superseded pull request runs, never main runs.
  8. Make a single if: always() gate job the only required check.
  9. Use BuildKit with a scoped cache for Docker images.
  10. Add merge_group to 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.

More in DevOps

DevOps

Mastering Docker for Scalable Development and Deployment

A complete guide to mastering Docker for scalable development and deployment—covering architecture, image optimization, networking, orchestration, and best practices for production environments.

Muhammad Rizwan

20 min read

Need help putting this into practice?

Talk to the engineers who write these posts. A 30-minute call gets you a rough plan, a timeline and a price range for your setup, whether or not we work together.

  • Reply within one business day
  • NDA on request
  • Senior engineer on every call