Optimize Turborepo caching in GitHub Actions

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.

ci.cache.turborepostatic · checkable from the repo
How StarSling works

AI agents open the PR

StarSling agents run this exact audit on your workflows, apply the fix, and open a reviewable PR automatically.

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

Avoid this

.github/workflows/release.yml
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

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.

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.

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
{
  "extends": ["//"],
  "tasks": {
    "build": {
      "outputs": ["dist/**"],
      "inputs": ["$TURBO_DEFAULT$", "!**/*.test.*", "!**/*.md", "!vitest.config.*"]
    }
  }
}

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.

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.

Prompt for your coding agent
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.

Go further

One fix, all of them, or forever.

You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.

  1. Fix this one thing

    Copy the prompt above

    Hand OPT3 / OPT41 / OPT42 / OPT52 / OPT53 / OPT58 / OPT59 / OPT60 to your coding agent and fix it in your repo today.

  2. Fix everything, once

    Install the ci-speedup skill

    One prompt audits your whole repo against all 73 ci-speedup patterns (these 8 plus 65 more) and hands your agent every fix at once. Open source, MIT, runs locally.

  3. Keep it fixed, forever

    Install the StarSling GitHub App

    Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.

Sources

1Turborepo: caching (opens in new tab)

2Turborepo: configuring turbo.json (opens in new tab)

3Turborepo: using environment variables (opens in new tab)

4Turborepo: system environment variables (opens in new tab)

Last updated 2026-08-20