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 -s | What it means | Common cause on a runner |
|---|---|---|
| Cacheable calls | Compiles ccache was allowed to handle | Denominator for the hit rate |
| Hits, direct | Manifest matched on source and includes | The fast path, working as intended |
| Hits, preprocessed | Direct mode missed, preprocessor output matched | Include file timestamps changed; add include_file_mtime to sloppiness |
| Misses | A new object was compiled and stored | Real source change, flag drift, or a changed compiler hash |
| Uncacheable calls | The invocation cannot be cached at all | Linking, -save-temps, multiple source files in one invocation |
| Local storage, cache size | Bytes on disk against max_size | At 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.
| Setting | Environment variable | Value | Why it matters on a runner |
|---|---|---|---|
| Cache location | CCACHE_DIR | ${{ github.workspace }}/.ccache | One path for the launcher and the cache action |
| Size limit | CCACHE_MAXSIZE | 2G | Bounds restore time and storage cost |
| Compiler identity | CCACHE_COMPILERCHECK | content | Survives a rebuilt runner image |
| Path rewriting | CCACHE_BASEDIR | ${{ github.workspace }} | Absolute paths hash the same across checkouts |
| Directory hashing | CCACHE_NOHASHDIR | true | Keeps the build directory out of the hash |
| Sloppiness | CCACHE_SLOPPINESS | time_macros,pch_defines,include_file_mtime | Caches 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-16xat 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.
| Line | Cold every run | Warm ccache at 88 percent hits |
|---|---|---|
| Compile phase | 21.0 min | 4.4 min |
| Cache restore and save | 0 min | 1.6 min |
| Configure, link, test | 5.0 min | 5.0 min |
| Job wall clock | 26.0 min | 11.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.