Using sccache on GitHub Actions Runners

sccache turns repeat compilations into cache fetches, but only when the cache directory outlives the job. Wire it up, persist it, and read the hit counts.

Last verified:

sccache is a compiler cache that stores the output of a compilation keyed on the hashed preprocessed source, the compiler binary, and the command line, so the second time that exact work is requested it returns an object file instead of running the compiler. On GitHub Actions it pays off only when the store outlives the job, because every runner is a fresh VM: point SCCACHE_DIR at a directory you restore each run, or run the job on a machine that already holds it.

This guide covers how to tell a cache miss from a non-cacheable call, the workflow wiring for a Rust or C++ build, every environment variable the tool reads in this setup, and a time model that converts hit rate into compile minutes and dollars.

Diagnosis

Start with the counters. sccache keeps per-server statistics and prints them with sccache --show-stats, and the shape of that output tells you which of three different problems you have. A run with the wiring in place looks like this:

Compile requests                 1284
Compile requests executed        1190
Cache hits                        842
Cache hits (Rust)                 842
Cache misses                      348
Cache timeouts                      0
Cache read errors                   0
Forced recaches                     0
Cache write errors                  0
Compilation failures                0
Cache errors                        0
Non-cacheable compilations          0
Non-cacheable calls                84
Non-compilation calls              10
Unsupported compiler calls          0
Average cache write             0.031 s
Average compiler                3.442 s
Average cache read hit          0.008 s

842 hits against 1,190 executed requests is a 70.8 percent hit rate, and Average cache read hit at 0.008 seconds against Average compiler at 3.442 seconds is why the rate matters. Three failure shapes replace that output.

Every line reads zero except cache misses. The store was empty when the job started. SCCACHE_DIR defaults to a path under the user cache directory, nothing on a stateless runner restores it, and the job compiles the whole tree and then throws the result away at the end. This is the common case and it is a workflow problem rather than a tuning problem.

Cache misses climb on a run where nothing changed. The hash key covers the compiler binary itself, the preprocessed source, and the command line flags. A toolchain bump, a change to RUSTFLAGS, a switch from debug to release, or a build that runs from a different absolute path all produce a legitimate miss on every unit. Pin the toolchain in rust-toolchain.toml and keep the flags identical between the run that fills the cache and the runs that read it.

Non-cacheable calls climb and cache hits stay flat. sccache refuses work it cannot key safely. For Rust the usual cause is incremental compilation, which sccache does not cache, so CARGO_INCREMENTAL has to be 0 in any job that uses it. For C and C++ the usual causes are flags that make the compilation depend on state outside the preprocessed source, and those calls fall through to the real compiler untouched.

One property separates sccache from timestamp-driven incremental builds. actions/checkout writes a fresh working tree and git stamps every file with the moment of the write, so make, ninja, and Cargo's own change detection see every source file as newer than its output and rebuild the tree. sccache hashes content, so a restored store still hits after a fresh checkout. Timestamp state is a separate problem covered in rust incremental compilation on GitHub Actions.

Fix

Give the store a home that survives the job. Three routes work, and the choice is about where the bytes live.

A local directory plus a cache action. SCCACHE_DIR holds the object store, a cache action restores it at job start and saves it at the end, and the whole mechanism is visible in the workflow file. WarpBuild's cache is a drop-in for actions/cache@v4, so the change is one line in the uses: field, and the caching documentation covers the key and restore-key behavior. This is the default recommendation for a single repository.

A snapshot runner that keeps the directory on the machine. Snapshot runners capture the whole runner VM mid-workflow, so later jobs boot with SCCACHE_DIR already populated and no archive transfer at job start. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only. The economics are covered in the cost model below, and the general trade between file caches and machine state is the subject of the persistent caches guide.

An object storage backend. sccache speaks S3, GCS, Azure Blob, Redis, and memcached natively, and the sccache project documentation lists the variables each backend reads. Objects are fetched on demand rather than as one archive, which is the right shape when several repositories should share one store or when the directory has grown past a few gigabytes.

The workflow below uses the first route on a Rust build. The install step writes SCCACHE_DIR into the job environment so the shell expands $HOME once and the cache path and the tool agree on the location.

name: build

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-8x
    env:
      RUSTC_WRAPPER: sccache
      CARGO_INCREMENTAL: "0"
      SCCACHE_CACHE_SIZE: 8G
      SCCACHE_IDLE_TIMEOUT: "0"
      SCCACHE_ERROR_LOG: /tmp/sccache.log
      SCCACHE_LOG: warn
    steps:
      - uses: actions/checkout@v5

      - name: Install sccache
        run: |
          mkdir -p "$HOME/.local/bin"
          curl -fsSL "https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz" \
            | tar -xz -C "$HOME/.local/bin" --strip-components=1 --wildcards '*/sccache'
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
          echo "SCCACHE_DIR=$HOME/.cache/sccache" >> "$GITHUB_ENV"

      - name: Restore sccache store
        uses: WarpBuilds/cache@v1
        with:
          path: ~/.cache/sccache
          key: sccache-${{ runner.os }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }}-${{ github.run_id }}
          restore-keys: |
            sccache-${{ runner.os }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }}-
            sccache-${{ runner.os }}-

      - name: Start sccache server
        run: |
          sccache --start-server
          sccache --zero-stats

      - name: Build
        run: cargo build --release --locked

      - name: Test
        run: cargo test --release --locked

      - name: Print sccache stats
        if: always()
        run: sccache --show-stats | tee -a "$GITHUB_STEP_SUMMARY"

Four details in that file earn their place. sccache --zero-stats runs after the server starts so the counters describe this run alone. The stats step carries if: always() so a failed build still reports why it was slow. The primary key ends in github.run_id, which never matches on restore and forces a fresh entry every run while restore-keys supplies the previous one, so the store grows instead of freezing at its first contents. And SCCACHE_IDLE_TIMEOUT is 0, which stops the background server shutting down between a long test step and the stats step.

On pull requests, swap the save half for WarpBuilds/cache/restore@v1 so branch runs read the store without writing new entries. Only the default branch then pays for storage.

Configuration

Every variable this setup depends on, and what breaks when it is wrong:

VariableWhat it controlsValue used here
SCCACHE_DIRLocation of the local object store$HOME/.cache/sccache
SCCACHE_CACHE_SIZECeiling on the local store; entries are evicted least recently used past it8G
SCCACHE_IDLE_TIMEOUTSeconds the background server stays alive with no requests; 0 disables shutdown0
RUSTC_WRAPPERTells Cargo to invoke rustc through sccachesccache
CARGO_INCREMENTALMust be 0; incremental invocations are not cacheable0
SCCACHE_ERROR_LOGFile the server writes errors to, readable after a failed run/tmp/sccache.log
SCCACHE_LOGLog level for that filewarn
CMAKE_C_COMPILER_LAUNCHERRoutes C compilation through sccache under CMakesccache
CMAKE_CXX_COMPILER_LAUNCHERRoutes C++ compilation through sccache under CMakesccache
SCCACHE_BUCKET, SCCACHE_REGIONS3 backend target, when the local directory is replacedunset

RUSTC_WRAPPER and CARGO_INCREMENTAL are Cargo's own variables, documented in the Cargo environment variable reference. For a C or C++ build the equivalent hook is CMake's compiler launcher variable, set on the configure line:

cmake -B build -G Ninja \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_C_COMPILER_LAUNCHER=sccache \
  -DCMAKE_CXX_COMPILER_LAUNCHER=sccache
cmake --build build

Two sizing rules follow from the eviction behavior. Keep SCCACHE_CACHE_SIZE above the working set of a full build, because a ceiling below it means the tail of each build evicts the head and the next run misses on work it just did. And keep the archive small enough that restoring it costs less than the compile minutes it removes, which the model below makes concrete.

Platform coverage decides where this pattern applies. Sccache runs on all four, so an ARM64 job takes the same wiring with warp-ubuntu-latest-arm64-8x in runs-on. Keep separate cache keys per architecture, since the compiler binary differs and no entry is shared across them.

Cost or Time Model

Assumptions first, so you can substitute your own step timings:

  • A Rust workspace on warp-ubuntu-latest-x64-8x, 8 vCPU and 32 GB, at $0.016 per minute.
  • A cold build spends 20.0 minutes in compilation.
  • A cache hit costs 5 percent of the compile it replaces, a conservative planning ceiling above the raw Average cache read hit to Average compiler ratio in the stats output, which leaves margin for network transfer and decompression that raw compiler time does not carry.
  • 400 runs per month.
  • A 5 GB store: restore takes 1.0 minute, a save takes 1.0 minute on the one run in five that writes a new entry, so 1.2 minutes of transfer amortized per run.

Compile minutes at hit rate h are 20 - 19h:

Hit rateCompile minutesCompile minutes savedCompile cost per run
0 percent20.000.00$0.320
30 percent14.305.70$0.229
50 percent10.509.50$0.168
70 percent6.7013.30$0.107
90 percent2.9017.10$0.046

The transfer sets the floor. 1.2 minutes of archive movement per run is paid whether the store hits or not, so the break-even hit rate is 1.2 divided by 19, which is 6.3 percent. Above that the store is worth carrying, and the counters in the diagnosis section tell you where you sit.

At a 70 percent hit rate, close to the sample output above, the run saves 13.3 compile minutes and pays 1.2 minutes of transfer, so 12.1 minutes net. Across 400 runs that is 4,840 runner minutes, $77.44 at $0.016 per minute. The store costs $1.00 per month at $0.20 per GB-month for 5 GB, plus $0.05 for the 480 cache operations a month (400 restores plus 80 saves, one run in five writing a new entry) at $0.0001 each, so $76.39 net per month on one workflow.

The snapshot route prices differently. A snapshot restore is $0.04 per job, which equals 2.5 minutes on warp-ubuntu-latest-x64-8x, against the 1.2 minutes the archive transfer costs. For an sccache directory alone the cache action wins. The snapshot wins when the same image also carries target/, the toolchain, and pulled container images, because all of that arrives in the same boot rather than as separate restores.

Runner rate against the GitHub-hosted baseline: 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), which is 27 percent lower list price. GitHub list price from the Actions billing reference, checked on 2026-08-13. The same 400 runs at the 70 percent row spend 6.70 compile minutes each, 2,680 minutes in total: $42.88 at $0.016 per minute against $58.96 at $0.022 per minute on the GitHub-hosted 8-core shape.

CI observability reports the per-step timings that turn these assumptions into your numbers, and ccache on GitHub Actions covers the same model for a C or C++ project that does not use Rust.

FAQ

Why is my sccache hit rate zero on GitHub Actions?

Three causes cover almost every case. The cache directory named by SCCACHE_DIR is never restored, so each job starts with an empty store. CARGO_INCREMENTAL is left at its default, and sccache treats incremental rustc invocations as non-cacheable. Or the toolchain moved, because the compiler binary is part of the hash key, so a new Rust or GCC version invalidates every entry at once. Run sccache --show-stats at the end of the job and read the counters before changing anything.

Does sccache work with Cargo incremental compilation?

No. sccache does not cache incremental rustc invocations, so a workflow that leaves incremental mode on gets a run where the counters climb under non-cacheable calls and the cache hits line stays flat. Set CARGO_INCREMENTAL=0 for any job that uses sccache. Incremental compilation and a compiler cache solve the same problem through different state, so pick one per job.

Should I use a cache action or an S3 bucket for sccache storage?

Use a cache action with the local directory backend when one repository drives the compilations, because the round trip is a single archive and the key logic is visible in the workflow file. Use the S3 or Redis backend when several repositories or several workflows should share one store, or when the directory has grown past the point where transferring it each run is cheaper than fetching individual objects on demand.

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.