---
title: "Optimize Turborepo Caching in GitHub Actions | StarSling"
description: "Find and fix the Turborepo settings that break caching in CI: TURBO_FORCE, remote:ro with no writer, missing outputs or inputs, unstable env vars in the hash."
url: https://starsling.dev/github-actions/optimizations/optimize-turborepo
canonicalUrl: https://starsling.dev/github-actions/optimizations/optimize-turborepo
---

# Optimize Turborepo caching in GitHub Actions

[GitHub Actions](https://starsling.dev/github-actions) / [Optimizations](https://starsling.dev/github-actions/optimizations) / Optimize Turborepo caching in GitHub Actions

- [How StarSling works](https://starsling.dev/)

Rule: ci.cache.turborepo. Detection mode: static. Last updated: 2026-08-20

A healthy Turborepo setup in CI restores unchanged tasks from cache in seconds; a handful of settings - TURBO_FORCE, a read-only remote cache with no writer, tasks missing outputs or inputs, unstable env vars in the hash - silently turn every run into a full rebuild.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [How to detect it](#how-to-detect-it)
- [Tradeoffs and safety](#tradeoffs)
- [Configure outputs and inputs so the cache can work](#outputs-and-inputs)
- [Keep the hash stable](#keep-the-hash-stable)
- [Verify it worked](#verify)
- [Related pages](#related-pages)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

Turborepo's whole value in CI is skipping work that already ran, and each of these misconfigurations defeats that from a different angle. TURBO_FORCE: true re-executes every task on every run. TURBO_CACHE: remote:ro reads a remote cache that stays cold when no sibling job writes it, so every lookup misses. A task with no outputs in turbo.json executes but stores nothing to restore. A task with no inputs hashes every git-tracked file in the package, so a README edit invalidates the build cache. And a rotating secret listed in globalEnv changes the hash for every package that reads it. In one customer monorepo, moving runtime-only API keys out of globalEnv and adding explicit inputs across 35 packages stopped secret rotation and doc edits from busting the cache repo-wide.

_.github/workflows/release.yml_

```yaml
jobs:
  release:
    runs-on: ubuntu-latest
    env:
      # No TURBO_FORCE, no TURBO_CACHE override: local + remote cache, read
      # and write, which on ephemeral runners is what makes the NEXT run fast.
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: my-team
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run build
```

<a id="avoid-this"></a>

## Avoid this

_.github/workflows/release.yml_

```yaml
jobs:
  release:
    runs-on: ubuntu-latest
    env:
      TURBO_FORCE: "true"        # every task re-executes, cache ignored
      TURBO_CACHE: "remote:ro"   # and even reads would miss: nothing writes this cache
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: my-team
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run build
```

<a id="how-to-detect-it"></a>

## How to detect it

1. Search the workflows for cache-disabling env vars: `grep -rn 'TURBO_FORCE' .github/workflows/` - any `TURBO_FORCE: true` outside a documented freshness requirement is a finding.
2. Search for cache-mode overrides: `grep -rn 'TURBO_CACHE' .github/workflows/` - flag `remote:ro` jobs, then confirm a sibling job or workflow writes the same cache with `remote:rw`; a read-only cache with no writer never hits.
3. List tasks with no outputs: `jq '.tasks // .pipeline | to_entries[] | select(.value.outputs == null or (.value.outputs | length == 0)) | .key' turbo.json`.
4. List tasks with no inputs (they hash every git-tracked file in the package): `jq '.tasks // .pipeline | to_entries[] | select(.value.inputs == null) | .key' turbo.json`, and repeat for package-level turbo.json files.
5. Read `globalEnv` and per-task `env` arrays for values that change between runs (GITHUB_RUN_ID, BUILD_NUMBER, timestamps) or that are read at runtime rather than inlined at compile time.

<a id="tradeoffs"></a>

## Tradeoffs and safety

- A release workflow sometimes forces a rebuild on purpose, for provenance. If that is the requirement, keep TURBO_FORCE there and say so in a comment; the finding is an UNDOCUMENTED force, not the force itself.
- Excluding files via inputs (`"$TURBO_DEFAULT$", "!**/*.md"`) under-invalidates if a build really does read an excluded file. Start with clearly non-build files: tests, docs, lint configs.
- Before removing an env var from globalEnv, confirm it is read at runtime and never inlined by a bundler (Next.js NEXT_PUBLIC_*, Vite import.meta.env, webpack DefinePlugin). A compile-time var removed from the hash produces stale builds - move runtime vars to globalPassThroughEnv instead.
- remote:rw from pull_request workflows lets fork PRs write your remote cache; keep untrusted triggers read-only and let trusted branches populate the cache.

<a id="outputs-and-inputs"></a>

## Configure outputs and inputs so the cache can work

outputs tells Turborepo what to store and restore; a task with no outputs re-executes even on a hash match, because there is nothing saved to bring back. inputs tells it what to hash; with no inputs, every git-tracked file in the package feeds the hash, so a changelog edit rebuilds the world. The exclusion form keeps the default git-aware behavior and subtracts files the build never reads:

_turbo.json_

```json
{
  "extends": ["//"],
  "tasks": {
    "build": {
      "outputs": ["dist/**"],
      "inputs": ["$TURBO_DEFAULT$", "!**/*.test.*", "!**/*.md", "!vitest.config.*"]
    }
  }
}
```

<a id="keep-the-hash-stable"></a>

## Keep the hash stable

Turborepo includes every var named in globalEnv and per-task env in the cache key. A value that changes between runs - GITHUB_RUN_ID, BUILD_NUMBER, a rotated API key - guarantees a miss for every package that lists it. Vars a task reads at runtime belong in globalPassThroughEnv, which makes them available to the process while keeping them out of the hash. In CI, `"ui": "stream"` and per-task `"outputLogs": "new-only"` also cut rendering overhead and log noise on cache hits.

<a id="verify"></a>

## Verify it worked

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this audit and open the fix as a reviewable PR:

```
Audit this repository's Turborepo caching in CI and fix what you find.

1. grep .github/workflows/ for TURBO_FORCE and TURBO_CACHE. Flag TURBO_FORCE: true with no
   comment documenting why, and flag TURBO_CACHE: remote:ro unless you can point at the
   sibling job or workflow that writes the same remote cache with remote:rw.
2. Parse every turbo.json (root and per-package). List tasks whose outputs are missing or
   empty, and tasks with no inputs. For missing inputs, propose "$TURBO_DEFAULT$" plus
   exclusions for test files, markdown, and lint configs.
3. Read globalEnv and per-task env. For each var, decide whether it is compile-time
   (inlined by the bundler) or runtime-only; propose moving runtime-only vars to
   globalPassThroughEnv. Do not remove a var you cannot classify - list it as a question.
4. Read the Turborepo caching and environment-variable docs linked on this page first.
5. Show the full diff and open a pull request; do not apply changes blindly. In the PR
   body, list each finding with file and line, and state how to verify: re-run the same
   commit and compare the turbo cache summary before and after.
```

Confirm the change landed:

1. Re-run the same commit twice; the second run's turbo summary should report cache hits (`FULL TURBO` when nothing changed) instead of re-executing every task.
2. Run `pnpm turbo run build --dry=json` and check each task's cache status field says it would restore rather than execute.
3. Compare wall-clock time of the build job before and after on an unchanged commit; a healthy cache turns minutes of task execution into seconds of restoration.

<a id="related-pages"></a>

## Related pages

- [GitHub Actions cache: dependencies, keys, and cache hits](https://starsling.dev/best-practices/github-actions/cache-dependencies)
- [Build and test only what changed in GitHub Actions](https://starsling.dev/best-practices/github-actions/build-only-affected)
- [Use Docker layer caching in GitHub Actions](https://starsling.dev/github-actions/optimizations/use-docker-layer-caching)

<a id="sources"></a>

## Sources

- [Turborepo: caching](https://turborepo.com/docs/crafting-your-repository/caching)
- [Turborepo: configuring turbo.json](https://turborepo.com/docs/reference/configuration)
- [Turborepo: using environment variables](https://turborepo.com/docs/crafting-your-repository/using-environment-variables)
- [Turborepo: system environment variables](https://turborepo.com/docs/reference/system-environment-variables)
