Turborepo on GitHub Actions: Cache Configuration

Turborepo caches task outputs by hash. Configure the remote cache, persist .turbo with WarpBuilds/cache, and size warp- runners for turbo run concurrency.

Last verified:

To make a Turborepo cache work on GitHub Actions, give turbo a remote cache through TURBO_API, TURBO_TOKEN, and TURBO_TEAM so task outputs are shared across every job and every branch, then persist the runner-local .turbo directory between runs so a hit costs a local file copy instead of a download. On WarpBuild runners the second half is a one-line swap: WarpBuilds/cache is a drop-in replacement for actions/cache@v4, and snapshot runners can carry node_modules and .turbo across jobs on the runner disk itself.

This page covers the turbo.json and workflow configuration for both cache layers, how the remote cache and the runner-local cache interact, what produces a miss in each, the sizing call for turbo run --concurrency against 8 and 16 vCPU runners, and a cost model against GitHub-hosted runner list prices.

Overview

Turborepo runs a task graph. For every task in every package it computes a hash, looks for a cached result under that hash, and either replays the stored outputs and logs or executes the task and stores the result. The speed of a monorepo pipeline on GitHub Actions comes down to how often that lookup hits and how expensive a hit is.

A hash covers more than the package source. Turborepo folds in the files matched by the task's inputs (defaulting to everything git tracks in that package), the hashes of the packages it depends on through dependsOn, the task's own definition in turbo.json, the values of the environment variables declared in env and globalEnv, and the files listed in globalDependencies along with the lockfile. Change any one of them and the hash moves.

There are two places a result can live.

The remote cache is a server that stores artifacts by hash and serves them to any machine holding a valid token. It is what makes a cache useful on GitHub Actions at all, because every job starts on a machine that has never seen your repository. A pull request job can replay a build produced an hour earlier by a main job on a different runner.

The filesystem cache lives on the runner itself, at .turbo/cache in the repository root by default. The cacheDir key in turbo.json and the TURBO_CACHE_DIR environment variable both move it. On a fresh virtual machine this directory is empty, so the filesystem cache does nothing for the first run of a job.

The two layers are ordered. Turborepo checks the filesystem cache first. On a miss it asks the remote cache, and when the remote cache answers it writes the artifact into the filesystem cache on the way through, so a second task in the same run that resolves to the same hash reads it locally. A hit from local disk is a file copy. A hit from the remote cache is a network round trip plus a download, repeated for every package that hits.

That difference is the whole reason to persist .turbo between GitHub Actions runs. A monorepo with 80 packages and a warm remote cache still pays 80 downloads on a fully cached run. With .turbo restored from the previous run, most of those become local reads and only the packages whose hashes actually moved touch the network.

What produces a miss differs by layer.

Remote cache. Entries are immutable and addressed by hash, so nothing is ever invalidated in place. A changed input produces a hash that has never been uploaded, which reads as a miss. Entries disappear only when the cache server evicts them under its own retention policy. One exception is worth knowing: with remoteCache.signature enabled, artifacts are signed with TURBO_REMOTE_CACHE_SIGNATURE_KEY and Turborepo rejects any artifact whose signature does not verify, so rotating that key makes every existing entry unusable at once.

Filesystem cache. It is a directory on a disk. On an ordinary GitHub Actions job it is destroyed with the virtual machine. It survives only as far as you carry it: restored by a cache action under a key you choose, or left in place on a snapshot runner. It also has no bound of its own, since every distinct hash adds another archive to it, which becomes a cost question further down this page.

Turborepo work belongs on the Linux runners, both because the sizes go up to 32 vCPU and because WarpBuild Cache is enabled by default on Linux runners and is not supported on Windows runners.

Configuration

Start with turbo.json. The parts that decide cache behavior are inputs, outputs, env, and the global entries:

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["tsconfig.base.json", ".nvmrc"],
  "globalEnv": ["NODE_ENV"],
  "remoteCache": {
    "signature": true
  },
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "env": ["NEXT_PUBLIC_API_URL"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**", "test/**", "vitest.config.ts"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Three details in that file earn their place. outputs with "!.next/cache/**" keeps a framework's own incremental cache out of the artifact, which otherwise makes every build artifact enormous. "outputs": [] on lint says the task produces no files, so a hit replays only the logs. env declares which environment variables the task is allowed to read; Turborepo's strict environment mode means an undeclared variable is invisible to the task rather than silently baked into the result.

Now the workflow. This job restores the pnpm store and the Turborepo filesystem cache, then runs the graph on an 8 vCPU WarpBuild runner:

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

env:
  TURBO_API: ${{ vars.TURBO_API }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_SIGNATURE_KEY }}

jobs:
  build-and-test:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Point turbo at a cache dir outside the workspace
        run: |
          echo "TURBO_CACHE_DIR=$HOME/.cache/turbo" >> "$GITHUB_ENV"
          echo "PNPM_STORE=$(pnpm store path)" >> "$GITHUB_ENV"

      - name: Restore pnpm store
        uses: WarpBuilds/cache@v1
        with:
          path: ${{ env.PNPM_STORE }}
          key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-

      - name: Restore turbo filesystem cache
        uses: WarpBuilds/cache@v1
        with:
          path: ~/.cache/turbo
          key: ${{ runner.os }}-turbo-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-turbo-

      - run: pnpm install --frozen-lockfile

      - name: Run the graph
        run: pnpm turbo run build lint test --concurrency=8 --summarize

The two cache steps carry different keys on purpose. The pnpm store key hashes the lockfile, because the store only changes when dependencies change. The turbo key uses the commit SHA with a restore-keys prefix, because the filesystem cache changes on every commit and you want the most recent one rather than an exact match. The prefix fallback is what turns a per-commit key into a rolling warm cache.

TURBO_CACHE_DIR moves the filesystem cache to $HOME/.cache/turbo, outside the checkout. That placement is deliberate and the reason is covered under bottlenecks below.

--summarize writes a run summary to .turbo/runs, listing every task, its hash, and whether it hit. When a pipeline stops hitting the cache and nobody knows why, that file names the task and the hash that moved.

Keeping node_modules and .turbo warm with snapshot runners

A cache action moves bytes over the network on every job. Snapshot runners take the other approach: they capture the runner VM's disk mid-workflow and boot later jobs from that image, so node_modules, the pnpm store, and .turbo are already on disk when the job starts.

The label carries the configuration. snapshot.enabled=true boots from the base image and lets you save a snapshot, while snapshot.key=<alias> boots from an existing snapshot for that alias when one exists. The usual arrangement builds the snapshot on main and reads it everywhere else:

jobs:
  build-and-test:
    runs-on: >-
      ${{ github.ref == 'refs/heads/main'
        && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
        || 'warp-ubuntu-latest-x64-8x;snapshot.key=monorepo-warm' }}
    steps:
      - uses: actions/checkout@v4
        with:
          clean: false

      - name: Point turbo at a cache dir outside the workspace
        run: echo "TURBO_CACHE_DIR=$HOME/.cache/turbo" >> "$GITHUB_ENV"

      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run build lint test --concurrency=8

      - name: Cleanup credentials
        if: github.ref == 'refs/heads/main'
        run: rm -rf $HOME/.ssh $HOME/.aws

      - name: Save snapshot
        if: github.ref == 'refs/heads/main'
        uses: WarpBuilds/snapshot-save@v1
        with:
          alias: "monorepo-warm"
          fail-on-error: true
          wait-timeout-minutes: 60

Four constraints from the snapshot documentation shape where this works.

Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. BYOC runners, Windows runners, and macOS runners are unsupported, and a snapshot label on any of them is silently ignored while the job runs normally. A monorepo whose iOS packages build on macOS runners keeps the cache action there.

/tmp does not persist, because the directory is cleaned on reboot. Any warm state has to live somewhere else, which is another reason $HOME/.cache/turbo is the right target for TURBO_CACHE_DIR.

Snapshots are deleted after 15 days. A repository that only builds occasionally will find the alias gone and boot from the base image, which is correct behavior rather than a failure.

Booting from a snapshot takes 45 to 60 seconds and can be slower than starting a default runner. The snapshot pays off when the state it carries saves more time than the boot costs, which the cost model below puts a number on.

Sizing

WarpBuild Linux x64 runners hold 4GB of memory per core across the range. These are the sizes a monorepo pipeline actually uses:

Runner labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128GB150GB SSD$0.064

turbo run defaults to --concurrency=10, which is a fixed number unrelated to the machine it lands on. On a 2 vCPU runner that oversubscribes badly; on a 16 vCPU runner it leaves cores idle. The flag also accepts a percentage of cores, so --concurrency=50% resolves to 8 on a 16x runner.

Pick the number from what the tasks themselves do.

Single-threaded tasks. tsc, eslint, and most codegen run on one core each. Set --concurrency to the vCPU count: 8 on warp-ubuntu-latest-x64-8x, 16 on warp-ubuntu-latest-x64-16x. Each task gets a core and 4GB, which covers a tsc process on all but the largest programs.

Tasks with their own worker pools. Vitest, Jest, and bundlers such as esbuild fan out internally. Ten of those at --concurrency=10 on an 8 vCPU runner produce dozens of worker processes fighting over 8 cores and 32GB. Drop --concurrency to 4 or 6 and cap the inner tool instead, with vitest run --maxWorkers=2 or jest --maxWorkers=2, so the total worker count stays near the core count.

Mixed graphs. Split the invocation. Run turbo run build lint --concurrency=16 on a 16x runner where the work is mostly single-threaded, then turbo run test --concurrency=4 where each task manages its own pool.

Memory is the constraint that decides 8x against 16x more often than cores do. Sixteen concurrent tsc processes on a large TypeScript graph can hold several gigabytes each, and 64GB on the 16x runner is what keeps them alive. If a run dies with heap errors at --concurrency=16 on 8x, the fix is either the larger runner or a lower concurrency, and the cheaper of the two depends on how long the run takes.

Sharding is the other lever. turbo run test --filter=...[origin/main] restricts the graph to packages affected since a base ref, and a matrix can split the remaining work across several smaller runners. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a 20-way matrix on warp-ubuntu-latest-x64-4x is a scheduling decision rather than a quota question.

What the cache layers cost

Cache and snapshot usage are metered separately from runner minutes:

MetricRate
Cache storage$0.20 per GB-month
Cache write, restore, or list$0.0001 per operation
Snapshot restore$0.04 per job
Snapshot storage$0.025 per snapshot-hour

Those rates make the snapshot decision arithmetic rather than opinion. A snapshot restore costs $0.04 per job. On warp-ubuntu-latest-x64-8x at $0.016 per minute, $0.04 buys 2.5 minutes of runner time, so a snapshot has to save more than 2.5 minutes per job to pay for itself. On warp-ubuntu-latest-x64-16x at $0.032 per minute the break-even drops to 1.25 minutes. Subtract the 45 to 60 second boot from whatever the snapshot saves before comparing.

Worked cost model

GitHub publishes per-minute list prices for its hosted runners at github.com/pricing and on the GitHub Actions minute multipliers reference. Checked on 2026-08-13, the standard ubuntu-latest runner for private repositories (2 vCPU) is $0.006 per minute, and larger Linux runners are $0.012 for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU.

MachinevCPUPer-minute rateSource
GitHub-hosted Linux larger runner8$0.022GitHub list price, checked 2026-08-13
GitHub-hosted Linux larger runner16$0.042GitHub list price, checked 2026-08-13
warp-ubuntu-latest-x64-8x8$0.016WarpBuild pricing
warp-ubuntu-latest-x64-16x16$0.032WarpBuild pricing

Take a monorepo with 80 packages running 1,200 pull request and push workflows a month on an 8 vCPU machine. With no cache reuse, turbo run build lint test takes 14 minutes. With a warm remote cache and a restored filesystem cache, the average run rebuilds only the packages that changed and finishes in 5 minutes.

ScenarioRateMonthly minutesMonthly cost
GitHub-hosted 8 vCPU, no cache reuse$0.022/min16,800$369.60
GitHub-hosted 8 vCPU, warm cache$0.022/min6,000$132.00
warp-ubuntu-latest-x64-8x, no cache reuse$0.016/min16,800$268.80
warp-ubuntu-latest-x64-8x, warm cache$0.016/min6,000$96.00

Stated as list-price arithmetic: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13.

Add the cache layer itself. An 8GB combined pnpm store and Turborepo archive costs 8 x $0.20 = $1.60 per month in storage. The two cache steps in the workflow above, each doing a restore and a save across 1,200 runs, come to 4,800 operations at $0.0001, or $0.48. The cache layer for this workload lands at $2.08 a month against $96.00 of runner time.

Now price the snapshot alternative for the same 1,200 runs: 1,200 restores at $0.04 is $48.00, plus one live snapshot held for a 730-hour month at $0.025 per snapshot-hour, or $18.25. That is $66.25 a month, which only beats the cache action if the snapshot saves more than 2.5 minutes per job. Snapshots win when installs are slow and the working set is large, and the cache action wins when the store restores quickly.

Bottlenecks

Checkout wiping the warm state. actions/checkout defaults clean to true, which runs git clean -ffdx before fetching and removes every ignored and untracked file in the workspace. node_modules and a .turbo directory at the repository root are both ignored files, so the default checkout deletes exactly the state a snapshot runner just booted with. Two fixes work together: set clean: false on checkout in snapshot jobs, and move the Turborepo cache out of the workspace with TURBO_CACHE_DIR. The same trap applies in reverse when following the snapshot documentation's cleanup advice, since a blanket git clean -ffdx before snapshot-save throws away the warm workspace you were trying to capture. Scope the cleanup to credentials instead.

Environment variables in the hash. Every variable named in env or globalEnv contributes its value to the hash. A variable that changes per run, a commit SHA, a build ID, a timestamp, produces a unique hash for every single run and a permanent miss. Audit the declared list, move genuinely per-run values into passThroughEnv so they reach the task without entering the hash, and confirm with --summarize that two consecutive runs on an unchanged package produce the same hash.

Overly broad inputs. The default inputs is every git-tracked file in the package, so editing a README invalidates the build. Declaring inputs explicitly, as in the turbo.json above, keeps documentation and configuration churn out of the hash. The opposite mistake also happens: an inputs list that omits a file the task actually reads produces a hit that replays stale output, which is worse than a miss.

Framework caches inside outputs. Next.js writes an incremental compiler cache into .next/cache, and Vite and similar tools have equivalents. Capturing those directories in outputs inflates every artifact, slows both upload and download, and drives cache storage cost. The "!.next/cache/**" exclusion is the fix.

Unbounded filesystem cache growth. Every distinct hash adds another archive under TURBO_CACHE_DIR. On a snapshot runner that directory grows across the snapshot chain until the disk fills; behind a cache action it grows the archive you upload and restore on every job, which shows up as both slower cache steps and higher storage cost at $0.20 per GB-month. Prune it on a schedule, deleting entries older than a chosen age, or rotate the cache key on a cadence so a fresh key starts from a smaller base.

Remote cache misses that look like network problems. When turbo cannot reach the remote cache it logs a warning and executes the task, so the pipeline stays correct and quietly gets slower. Check that TURBO_TOKEN is present on pull request events, since a token stored as a repository secret is not available to workflows triggered by pull_request from a fork. Runs from forks fall back to the filesystem cache alone.

For diagnosing the runs where none of this is obvious, WarpBuild ships CI observability, which correlates OpenTelemetry-based system metrics from the runner agent with GitHub Actions job logs, so a turbo run that is memory-starved at high concurrency looks different from one stalled on cache downloads. The Action Debugger pauses a workflow and opens an SSH session on the runner, which is the fastest way to inspect TURBO_CACHE_DIR and the .turbo/runs summary on the machine that produced them. Both are described in the WarpBuild caching documentation and its neighbors, and the broader pattern of carrying state between runs is covered in the guide to persistent caches for GitHub Actions.

Proof

For a monorepo the change is confined to the runs-on label plus the two cache steps above. Nothing in turbo.json needs to move.

Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, which is the property a task-graph monorepo leans on hardest, because the natural shape of turbo run --filter sharding is a wide matrix that all starts at once.

If the monorepo has to build inside your own cloud account for data residency or policy reasons, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. Note the tradeoff for this page specifically: snapshot runners are unavailable on BYOC, so a BYOC monorepo keeps the remote cache plus a cache action and skips the snapshot layer entirely.

Two neighboring pages cover adjacent decisions. Nx task caching on GitHub Actions walks the same layering for an Nx graph, and Node.js dependency caching on GitHub Actions covers the single-package case where the package manager store is the only cache worth keeping. The runner-side detail behind the snapshot workflow above lives on the snapshot runners page.

FAQ

Does the Turborepo remote cache replace actions/cache?

No. The remote cache stores task outputs keyed by Turborepo's hash. A cache action stores the runner-local .turbo directory and the package manager store keyed by a key you write. Most monorepos configure both, because the remote cache still costs a download on every hit.

What invalidates a Turborepo cache entry?

Nothing invalidates an entry in place. Turborepo computes a new hash when a package input, a dependency hash, the task definition in turbo.json, a declared env value, or a globalDependencies file changes, and a new hash is simply a miss. Old entries stay until the cache server evicts them.

What concurrency should turbo run use on a warp- runner?

Match --concurrency to the vCPU count when tasks are single threaded, so 8 on warp-ubuntu-latest-x64-8x and 16 on warp-ubuntu-latest-x64-16x. Drop it to 4 or 6 when each task spawns its own worker pool, and cap the inner tool instead.

Do snapshot runners work with Turborepo on macOS or Windows jobs?

No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. Snapshot labels on macOS, Windows, or BYOC runners are silently ignored and the job runs normally without snapshot behavior.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.