Caching Turborepo Outputs in GitHub Actions Without a Remote Cache
A Turborepo remote cache is the best way to share task results between CI runs, but not every team can use one: the hosted service may not be approved, and self-hosting a cache server is a project of its own. GitHub Actions' built-in cache can fill the gap. By persisting Turborepo's local cache directory between workflow runs, keyed sensibly, you get most of the benefit — unchanged packages replayed instead of rebuilt — with nothing but a workflow change. This guide sets it up, explains the key and restore-key strategy that makes it effective, and covers the limits compared with a real remote cache. The whole setup is one workflow step, so it is also a cheap way to measure how much caching would help before committing to any infrastructure.
How it works
Turborepo stores each task's outputs and logs in a local cache directory, .turbo/cache by default in Turborepo 2. On a later run with the same task hash, it restores from there. In CI, every job starts on a fresh runner with an empty directory — so by default nothing is ever reused, and every pipeline pays the full cost of building and testing every package in scope, even when most of them have not changed in weeks. Saving that directory with actions/cache at the end of a run and restoring it at the start of the next turns Turborepo's local cache into a cross-run cache. How task hashes are computed is covered in Turborepo Pipeline Configuration.
Workflow configuration
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 2 }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc, cache: pnpm }
- name: Restore Turborepo cache
uses: actions/cache@v4
with:
path: .turbo/cache
key: turbo-${{ runner.os }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ github.ref_name }}-
turbo-${{ runner.os }}-main-
turbo-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint test build --cache-dir=.turbo/cache
The key design is the important part:
- The primary key includes the commit SHA, so every run saves a new entry. GitHub Actions cache entries are immutable; a key that never changes would be saved once and never updated.
restore-keysfall back by prefix, most specific first: the latest entry for this branch, then the latest frommain, then any entry for this OS. A new pull request branch starts frommain's cache, which is exactly what you want.- The OS is in every key, because task outputs can differ by platform.
--cache-dirmakes the location explicit, so the cached path and Turborepo's path cannot drift apart.
GitHub Actions scopes caches by branch: a workflow can restore caches created on its own branch and on the repository's default branch, but not on other feature branches. That matches the fallback chain above.
Keeping the cache small and useful
The Actions cache has a per-repository size limit (10 GB by default), and old entries are evicted when it fills up or after a period without access. Turborepo's cache directory only grows, because each run adds entries for new hashes. Without pruning, every saved archive contains everything ever cached on that runner lineage, and uploads get slower.
Two simple controls:
- name: Prune old Turborepo cache entries
if: always()
run: find .turbo/cache -type f -mtime +7 -delete || true
Place it before the save step (the cache action saves in its post step, after later steps finish). Pruning files older than a week keeps the archive proportional to recent work. And make sure task outputs in turbo.json include only what later steps need — caching node_modules/.cache or coverage reports you never replay just inflates every archive.
Checking that the cache is working
A cache step that restores nothing, or a Turborepo run that ignores the restored directory, still produces a green pipeline, so verify the setup explicitly on the first few runs.
The cache action logs which key it matched: Cache restored from key: turbo-Linux-main-3f9c... for a prefix match, or Cache not found for input keys for a cold start. Turborepo's summary line then tells you what it did with the restored entries:
Tasks: 38 successful, 38 total
Cached: 31 cached, 38 total
Time: 1m12s
If the cache was restored but Cached stays near zero, the directories do not line up (check --cache-dir against the cached path) or hashes differ between runs. For the latter, run with --summarize in two consecutive workflow runs and compare the hashes of a task that should have hit; the summary JSON under .turbo/runs/ lists every input that fed each hash. The most common culprits are environment variables that change per run, such as GITHUB_RUN_ID or a timestamp declared under env, which should be pass-through variables instead.
Add a small step that prints the cache size before saving, so growth is visible in logs:
- run: du -sh .turbo/cache || true
Remote cache features you do not get
It is worth being clear about the gap so the decision to move to a real remote cache is informed. The Actions cache is archive-based: it restores one directory snapshot per job, so two jobs running in parallel cannot share results with each other within the same workflow run, and a result produced in the middle of a run only becomes available to other runs after the job finishes and uploads. Developers get nothing from it, because the cache lives only inside GitHub Actions. And access control is coarse — any workflow on the branch lineage can restore the archive. A remote cache fixes all three: per-task uploads available immediately, developer read access, and token-based permissions. For many teams the Actions cache is a good first step that proves the value of caching before investing in infrastructure.
Matrix jobs and parallel workflows
When a workflow runs several jobs — a test matrix across Node.js versions, or separate lint, test and build jobs — each job restores and saves independently. Give each job a key prefix that reflects what it runs, for example turbo-${{ runner.os }}-test-node${{ matrix.node }}-, so jobs do not overwrite each other's archives with unrelated content. Jobs that run the same tasks can share a prefix. Parallel jobs will not see each other's results within the same workflow run; that is the main thing a real remote cache does better.
Worked example: halving CI time without infrastructure
A team whose security review had not yet approved an external cache service adds the workflow above. On main, each run now restores the previous run's cache; pull requests restore main's cache on their first run and their own on later pushes. Median pull request CI drops from fourteen to six minutes, because most packages are unchanged and replay their build and test results. The weekly pruning step keeps archives around 400 MB. When the team later sets up a self-hosted remote cache, the workflow change is removing the cache step and adding two environment variables — see Self-Hosting a Turborepo Remote Cache.
Prevention and CI/CD guardrails
- Include the SHA in the save key and rely on
restore-keysfor reuse. - Include the OS (and Node.js major, if outputs depend on it) in keys.
- Prune old entries before saving to control archive size.
- Keep secrets out of task outputs and logs, because cache archives can be restored by later runs on the same branch lineage.
Frequently Asked Questions
Should I also cache node_modules?
No. Cache the pnpm store (which setup-node does with cache: pnpm) and reinstall; restoring node_modules directly is fragile across lockfile changes.
Can pull requests from forks use the cache? They can restore caches from the base branch but, by default, cannot save to it, which is the safe behaviour: untrusted code should not populate caches that trusted runs restore.
Why do I get fewer cache hits than expected? Usually because task hashes differ between runs — an environment variable that changes every run, or inputs that include generated files. Debug the hashes as described in Debugging Why a Turborepo Task Is Never Cached.
Does this work on other CI providers?
Yes. GitLab CI's cache: with a key that includes the commit and a fallback_keys list, CircleCI's save_cache/restore_cache with prefix keys, and similar features elsewhere implement the same pattern. The principle — save per commit, restore by prefix, include the platform — carries over directly.
Related
- CI/CD Pipeline Optimization for Monorepos covers the full set of CI speed techniques.
- Remote Caching Setup explains remote caches and when they are worth it.
- Caching the pnpm Store in GitHub Actions handles the dependency side of CI caching.
- Optimizing Turborepo Remote Cache for CI tunes the remote alternative.