Skip to content
compiler.dev

Fastest CI for Turborepo Monorepos on GitHub Actions

Last updated

Fast Turborepo CI comes from two things: running only packages that changed, and replaying cached task output from a remote cache. Turborepo gives you --filter for the first and a Remote Cache for the second. This guide is about wiring both into GitHub Actions and the git settings that decide whether they work. For the Nx comparison, see the Nx and Turborepo overview.

Step 1: run only changed packages

turbo run build test lint --filter="...[origin/main]"

The [origin/main] part selects packages with changes since origin/main. The leading ... adds everything that depends on them, which is what you want in CI: a change in a shared library must retest its consumers. Without ... you only get the packages whose own files changed.

pnpm uses the same selector syntax; see pnpm workspaces. Recent Turborepo versions also have an --affected flag that picks a base for you; check the Turborepo docs for your installed version.

Fetch-depth

A filter against origin/main needs that ref and enough history to find the merge base. actions/checkout fetches one commit by default, so use one of these:

- uses: actions/checkout@v6
  with:
    fetch-depth: 0       # full history: safest

The Turborepo GitHub Actions guide (checked October 2026) uses fetch-depth: 2 in its examples, which is enough for comparing against the previous commit on main but not for a long-lived PR branch compared with origin/main. For PRs, full history or filter: tree:0 with fetch-depth: 0 is the safe choice. For pushes to main, comparing against the previous commit is common:

- run: turbo run build test --filter="...[HEAD^1]"

On pull requests you can compare with the base branch dynamically:

- run: turbo run build test --filter="...[origin/${{ github.base_ref }}]"
  if: github.event_name == 'pull_request'

Step 2: Remote Cache

Turborepo's local cache lives in node_modules/.cache/turbo, which disappears with each fresh runner. A Remote Cache stores outputs by hash so a different runner, branch or teammate gets the same result without running the task.

The Turborepo docs describe two ways to authenticate in GitHub Actions. With a token, set TURBO_TOKEN as a secret and TURBO_TEAM as a variable:

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Their guide also describes an OIDC setup using vercel/setup-turborepo-remote-cache-action, which needs id-token: write permission and exchanges a short-lived GitHub token for a cache token. It must run before any step that calls turbo. Prefer OIDC when you can; there is no long-lived secret to leak.

A cache hit shows in the output as cache hit, replaying logs with a hash, and the summary line reports Cached: N cached, M total. A fully cached run says FULL TURBO.

If you do not want a hosted cache, you can run a self-hosted cache server that implements Turborepo's remote cache API, or fall back to actions/cache on .turbo:

- uses: actions/cache@v6
  with:
    path: .turbo
    key: turbo-${{ runner.os }}-${{ github.sha }}
    restore-keys: |
      turbo-${{ runner.os }}-

This is the rolling-cache pattern from Caching in GitHub Actions. It works if you point Turborepo's cache directory at .turbo. It is limited by the 10 GB cap and branch scope; see cache limits.

Step 3: sharding and parallelism

Turborepo runs tasks in parallel on one machine, bounded by --concurrency (default 10). It does not distribute tasks across machines itself. Your options:

  1. More cores. Often the cheapest. --concurrency can be raised on bigger runners.
  2. Matrix by task. Run lint, test and build as separate jobs that each use the remote cache, so work already done by one job is replayed in another.
  3. Matrix by package group. Use --filter per app. Pair with test sharding for large test suites.
strategy:
  fail-fast: false
  matrix:
    task: [lint, test, build]
steps:
  - run: turbo run ${{ matrix.task }} --filter="...[origin/main]"

Each matrix job pays for checkout and install, so cache dependencies well; see pnpm caching and npm caching.

A complete workflow

name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  ci:
    runs-on: [self-hosted, Linux, compiler-ci]   # or ubuntu-latest
    timeout-minutes: 20
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - name: Run changed packages
        run: pnpm turbo run lint test build --filter="...[origin/${{ github.base_ref || 'main' }}]"

On a push, github.base_ref is empty, so the expression falls back to main. On main itself that compares main with main and selects nothing, so for pushes use --filter="...[HEAD^1]" or just run everything and let the cache replay.

On compiler.dev, the runs-on line gives you an 8 vCPU machine at $0.012 per minute with dependency caches ready, and you undo it by changing the line back. In our measured run of a Turborepo, Next.js, tRPC and MySQL project, CI went from 3m29s on GitHub-hosted to 1m53s (see benchmarks).

Mistakes that defeat filtering

  1. Shallow clone. Missing merge base makes Turborepo treat many packages as changed.
  2. Cache key inputs too broad. Files outside the package that are hashed into a task (global dependencies in turbo.json) invalidate everything when they change.
  3. Environment variables not declared. If a task reads an env var that is not in env or globalEnv, cache entries may be wrongly shared or wrongly missed.
  4. Different TURBO_TEAM between jobs. Jobs then use different caches.
  5. Untrusted PRs writing the cache. Limit write access to trusted branches.

FAQ

What does ...[origin/main] mean in turbo --filter?

Packages changed since origin/main, plus their dependents because of the leading ....

Do I need Vercel for the Remote Cache?

The documented hosted option is Vercel's. You can also run a compatible self-hosted cache server.

Why does CI rebuild everything on main?

On pushes there is no PR base. Compare with the previous commit or the last green one, or rely on the remote cache to replay.

Does Turborepo distribute tasks across machines?

Not by itself. Use a matrix or bigger runners, as described above.

Run this monorepo build on compiler.dev 200 free minutes

Start free