Using ccache for C and C++ Builds on GitHub Actions

ccache returns nothing on GitHub Actions until CCACHE_DIR survives the job. Wire the compiler launcher, cap the size, restore and save it, price the result.

Last verified:

ccache turns a repeat compile of an unchanged translation unit into a hash lookup instead of a compiler invocation, and on GitHub Actions it returns nothing until CCACHE_DIR survives the job, because the runner VM is created for the job and destroyed when the job ends. A working setup has three parts: a compiler launcher so every compile goes through ccache, a size limit so the directory stays cheap to move, and a restore and save pair that carries the directory between runs.

This guide covers what ccache hashes and why that matters on a fresh runner, the configuration for a CMake or Make build, a workflow that restores, builds, prints statistics, and saves on success, and a time model that prices a cold compile against a warm cache at a stated hit rate. It sits under the persistent caches for GitHub Actions runs hub.

Diagnosis

What ccache actually hashes

ccache is a compiler cache: it computes a hash from the preprocessed source, an identity for the compiler, the command line, and a few configuration values, then looks that hash up in CCACHE_DIR. In direct mode it skips the preprocessor: it keeps a manifest of the include files a source pulled in on the previous compile and hashes those files instead, which is the fast path and the one you want hitting. The hashing rules, the manifest, and every setting named below are documented in the ccache manual.

Neither path reads file modification times from the working tree. That is why ccache still hits after actions/checkout rewrites every source timestamp, while make and ninja compare mtimes, decide the whole tree is out of date, and re-invoke the compiler on all of it. ccache does not stop those invocations. It makes most of them cost a lookup and a copy.

The three reasons ccache reports no hits on GitHub Actions

The directory was never restored. A fresh job starts with an empty CCACHE_DIR, so the first run after adding ccache is a full cold compile plus the cost of writing every object into the cache. Nothing is wrong. There is simply no prior state on the machine.

The compiler hash changes between runs. The default compiler_check is mtime, meaning ccache hashes the compiler binary's size and modification time. Runner images install their toolchains when the image is built, so a rebuilt image can ship a byte-identical compiler with a different mtime, and every lookup misses. Setting CCACHE_COMPILERCHECK=content hashes the binary itself and removes the whole class of failure.

Absolute paths differ. Debug information, __FILE__, and the build directory path all end up in the hash. Setting CCACHE_BASEDIR to the workspace rewrites absolute paths under it into relative ones, and CCACHE_NOHASHDIR=true keeps the current directory out of the hash for debug builds.

Two more misses are common in C++ specifically. Sources that expand __DATE__ or __TIME__ are refused unless sloppiness includes time_macros, and precompiled headers need pch_defines before a compile that consumes one will cache.

Reading the statistics

Run ccache --show-stats --verbose at the end of the build step and read the counters against this table before changing any configuration.

Counter in ccache -sWhat it meansCommon cause on a runner
Cacheable callsCompiles ccache was allowed to handleDenominator for the hit rate
Hits, directManifest matched on source and includesThe fast path, working as intended
Hits, preprocessedDirect mode missed, preprocessor output matchedInclude file timestamps changed; add include_file_mtime to sloppiness
MissesA new object was compiled and storedReal source change, flag drift, or a changed compiler hash
Uncacheable callsThe invocation cannot be cached at allLinking, -save-temps, multiple source files in one invocation
Local storage, cache sizeBytes on disk against max_sizeAt the ceiling means eviction is discarding warm objects

A hit rate that sits near zero on the second run points at the compiler hash or the path settings. A hit rate that starts high and decays over a week points at eviction, so raise CCACHE_MAXSIZE or shrink what the build produces.

Decide whether the transfer pays

The restore and the save are runner minutes. Time both steps on a real run before tuning anything else. WarpBuild's CI observability collects system metrics from the runner agent and correlates them with GitHub Actions job logs, which separates a compile phase that is saturating every core from a job that is sitting in a network transfer. The Action Debugger handles the stubborn case: pause the job, open a session on the live runner, and read ccache -s in place instead of guessing from logs.

Fix

1. Install ccache and point CCACHE_DIR inside the workspace. A path such as ${{ github.workspace }}/.ccache gives the compiler launcher and the cache action the same target and keeps CCACHE_BASEDIR rewriting predictable.

2. Set the compiler launcher rather than editing the build. CMake takes -DCMAKE_C_COMPILER_LAUNCHER=ccache and -DCMAKE_CXX_COMPILER_LAUNCHER=ccache, which wraps every compile invocation for both the Ninja and the Makefile generators. A hand-written Makefile takes CC="ccache gcc" and CXX="ccache g++".

3. Cap the directory with CCACHE_MAXSIZE. 2G suits most mid-size codebases. The ceiling bounds restore time, bounds the cache storage line on the bill, and makes eviction visible in the statistics instead of silent.

4. Fix the hash inputs before touching keys. CCACHE_COMPILERCHECK=content, CCACHE_BASEDIR, CCACHE_NOHASHDIR=true, and a CCACHE_SLOPPINESS list that covers time_macros and pch_defines. Key tuning cannot rescue a build whose hash inputs move every run.

5. Restore on every run, save on the default branch. Key the entry on the commit so it never matches exactly, let restore-keys fall back to the newest entry on the prefix, and gate the save on success() and a push to main. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 and is enabled by default on WarpBuild Linux runners; the mechanics are in the caching documentation.

6. Zero the counters at the start of the job. ccache -z makes the statistics describe this run rather than the accumulated history inside the restored directory.

7. Move to machine state when the directory outgrows the transfer. Once the ccache directory plus the dependency tree is tens of gigabytes, the restore stops paying. Snapshot runners boot a later job from a captured VM image with the cache already on disk, which removes the transfer from the critical path. Package manager trees have their own answer in vcpkg and Conan caching on GitHub Actions.

Configuration

Environment

Set these once at the workflow level so every job and every step agrees.

SettingEnvironment variableValueWhy it matters on a runner
Cache locationCCACHE_DIR${{ github.workspace }}/.ccacheOne path for the launcher and the cache action
Size limitCCACHE_MAXSIZE2GBounds restore time and storage cost
Compiler identityCCACHE_COMPILERCHECKcontentSurvives a rebuilt runner image
Path rewritingCCACHE_BASEDIR${{ github.workspace }}Absolute paths hash the same across checkouts
Directory hashingCCACHE_NOHASHDIRtrueKeeps the build directory out of the hash
SloppinessCCACHE_SLOPPINESStime_macros,pch_defines,include_file_mtimeCaches sources with date macros and precompiled headers

CMake and Make

cmake -S . -B build -G Ninja \
  -DCMAKE_BUILD_TYPE=RelWithDebInfo \
  -DCMAKE_C_COMPILER_LAUNCHER=ccache \
  -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
cmake --build build --parallel 16

make -j"$(nproc)" CC="ccache gcc" CXX="ccache g++"

For an autotools project, export CC and CXX with the launcher before running ./configure, because the generated makefiles bake in whatever the compiler variables held at configure time.

The workflow

name: cpp-ccache

on:
  pull_request:
  push:
    branches: [main]

env:
  CCACHE_DIR: ${{ github.workspace }}/.ccache
  CCACHE_MAXSIZE: 2G
  CCACHE_COMPILERCHECK: content
  CCACHE_BASEDIR: ${{ github.workspace }}
  CCACHE_NOHASHDIR: "true"
  CCACHE_SLOPPINESS: time_macros,pch_defines,include_file_mtime

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-16x
    timeout-minutes: 40
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - name: Install ccache and ninja
        run: sudo apt-get update && sudo apt-get install -y ccache ninja-build

      - name: Restore the ccache directory
        id: ccache-restore
        uses: WarpBuilds/cache/restore@v1
        with:
          path: ${{ github.workspace }}/.ccache
          key: ccache-linux-x64-${{ github.sha }}
          restore-keys: |
            ccache-linux-x64-

      - name: Reset the counters so statistics describe this run
        run: ccache -z

      - name: Configure with ccache as the compiler launcher
        run: >
          cmake -S . -B build -G Ninja
          -DCMAKE_BUILD_TYPE=RelWithDebInfo
          -DCMAKE_C_COMPILER_LAUNCHER=ccache
          -DCMAKE_CXX_COMPILER_LAUNCHER=ccache

      - name: Build
        run: cmake --build build --parallel 16

      - name: Print ccache statistics
        run: ccache --show-stats --verbose

      - name: Test
        run: ctest --test-dir build --output-on-failure --parallel 16

      - name: Save the ccache directory
        if: success() && github.ref == 'refs/heads/main'
        uses: WarpBuilds/cache/save@v1
        with:
          path: ${{ github.workspace }}/.ccache
          key: ${{ steps.ccache-restore.outputs.cache-primary-key }}

Three details carry the design. The key ends in github.sha so it never matches exactly and restore-keys always resolves to the newest entry on the prefix. The save runs only when the build and tests passed, so a broken run never seeds later ones. And the architecture is in the key prefix, because objects built on warp-ubuntu-latest-arm64-16x cannot serve an x64 job. A matrix that spans architectures keeps one key prefix per architecture. The Rust and mixed-toolchain version of the same setup is on using sccache on GitHub Actions.

Cost or Time Model

Every cost number below comes from the pricing page and the GitHub billing reference, with the checked-on date attached. Replace the build inputs with numbers from your own run history.

  • A CMake project with roughly 1,400 translation units.
  • A cold build on warp-ubuntu-latest-x64-16x at 26 minutes, of which 21 minutes is compilation and 5 minutes is configure, link, and test.
  • A pull request that lands an 88 percent ccache hit rate, where a hit costs about one tenth of a full compile.
  • A 3 GB ccache directory, restored and saved in 1.6 minutes of runner time.
  • 700 pull request builds per month.
LineCold every runWarm ccache at 88 percent hits
Compile phase21.0 min4.4 min
Cache restore and save0 min1.6 min
Configure, link, test5.0 min5.0 min
Job wall clock26.0 min11.0 min
Cost per run at $0.032 per minute$0.83$0.35
700 runs per month$582.40$246.40

The warm compile phase is the misses at full cost, 0.12 times 21 minutes, plus the hits at a tenth of that cost, 0.88 times 21 times 0.1. Two cache line items sit on top of the runner minutes: cache storage at $0.20 per GB-month, so $0.60 for the 3 GB directory, and cache operations at $0.0001 each, so $0.14 for two operations across 700 runs. Both are rounding error against the 15 minutes per run the cache returns.

The break-even is worth computing once. The restore and save cost 1.6 minutes, and each percentage point of hit rate returns 0.189 minutes of compile time in this model, so the pair pays for itself above roughly a 9 percent hit rate. Any C++ repository where most pull requests touch a small share of the tree clears that easily, which is why the interesting tuning question is the miss causes in the Diagnosis section rather than whether to cache at all.

Rate context for sizing: warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13 in the Actions billing reference. Warm at 11 minutes, the same 700 runs list at $246.40 against $323.40 on the GitHub-hosted 16-core shape. warp-ubuntu-latest-x64-8x at $0.016 per minute is the size to try first when ccache -s shows the build is spending its time on lookups rather than on compiles, since a cache-heavy run stops scaling with cores. Sizing against link time, precompiled headers, and unity builds is covered on C++ builds on GitHub Actions.

FAQ

Why is my ccache hit rate zero on GitHub Actions even though the directory restored?

The usual cause is the compiler hash. ccache identifies the compiler by size and modification time by default, and a runner image installs its toolchain at image build time, so a rebuilt image changes the mtime and every lookup misses. Set CCACHE_COMPILERCHECK=content so the compiler binary is hashed by content. The second cause is absolute paths, which is fixed by setting CCACHE_BASEDIR to the workspace and CCACHE_NOHASHDIR=true.

Where should CCACHE_DIR live in a GitHub Actions job?

Inside the workspace, such as ${{ github.workspace }}/.ccache, so one path works for the compiler launcher and for the cache action that restores and saves it. The home directory default under /home/runner/.cache/ccache also works, but a workspace path keeps the cache path identical across runners and makes CCACHE_BASEDIR rewriting predictable.

Should the ccache directory be saved on every run?

No. Save on pushes to the default branch and let pull requests restore through restore-keys. That keeps one authoritative entry per branch point, avoids paying the upload on every pull request, and stops a failed or partial build from writing an entry that later runs inherit.

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.