C++ Builds on GitHub Actions: Caching and Sizing
Fast C++ builds on GitHub Actions come from a persistent ccache, warp- runners sized for parallel compiles, and tamed link steps. Cost math inside.
Last verified:
C++ builds get fast on GitHub Actions through three levers: a compiler cache that survives between runs, enough vCPUs to keep parallel compilation fed, and a link step kept off the critical path. WarpBuild covers the first two directly, with warp- labeled runners from 2 to 32 vCPUs billed per minute and a cache service that keeps a ccache directory warm across jobs.
Switching takes one line. Change runs-on from a GitHub-hosted label to a warp- label, persist CCACHE_DIR through a cache step, and the same CMake project builds on a machine sized for the compile graph. The sections below cover the exact configuration, how to pick -j against each size's memory ceiling, and the arithmetic behind the bill.
Overview
A C++ build spends GitHub Actions minutes in four places: compiling third-party dependencies, compiling your own translation units, linking binaries, and running tests. Compilation is embarrassingly parallel, since every translation unit compiles independently. Linking is the serial tail, one process per binary, and it is where memory pressure concentrates.
Two properties of C++ make hosted runner choice matter more than in most ecosystems. First, the language recompiles a lot: every .cpp file re-parses every header it includes, so a header touch can invalidate hundreds of translation units. Second, a fresh virtual machine has an empty compiler cache, so without persistence every push pays for a full rebuild of code that has been stable for months.
WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. For most C++ pipelines the Linux runners do the work: the runner catalog lists Ubuntu 22.04, 24.04, and 26.04 images on x64 and Ubuntu 24.04 and 26.04 on ARM64, each in sizes from 2 to 32 vCPUs with 150GB SSDs. The images carry the same tooling as GitHub-hosted runners, so gcc, clang, CMake, and Ninja are already present. Runners are ephemeral VMs, freshly allocated per job and destroyed afterward, which is exactly why the compiler cache has to live outside the machine.
That external home is WarpBuild Cache, enabled by default on all Linux runners. The cache documentation covers the general mechanics; the next section applies them to ccache and CMake specifically.
Configuration
Here is a working C++ pipeline on WarpBuild runners. The build and test job runs on 16 vCPUs with ccache persisted through WarpBuilds/cache, and a light format check runs on 4 vCPUs.
name: cpp
on:
push:
branches: [main]
pull_request:
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 2G
jobs:
build-and-test:
runs-on: warp-ubuntu-latest-x64-16x
steps:
- uses: actions/checkout@v4
- run: sudo apt-get update && sudo apt-get install -y ccache
- name: Restore compiler cache
id: ccache
uses: WarpBuilds/cache/restore@v1
with:
path: ${{ github.workspace }}/.ccache
key: ${{ runner.os }}-ccache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-ccache-
- name: Configure
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: Test
run: ctest --test-dir build --output-on-failure --parallel 16
- name: Show cache stats
run: ccache -s
- name: Save compiler cache
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/cache/save@v1
with:
path: ${{ github.workspace }}/.ccache
key: ${{ steps.ccache.outputs.cache-primary-key }}
format:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- run: |
find src include -name '*.cpp' -o -name '*.h' \
| xargs clang-format --dry-run --WerrorFour details matter here.
The compiler launcher does the caching. CMAKE_C_COMPILER_LAUNCHER and CMAKE_CXX_COMPILER_LAUNCHER wrap every compile invocation in ccache, so an unchanged translation unit becomes a hash lookup instead of a compile. No build system rewrite is involved, and the same flags work for Makefile generators.
The cache key ends in github.sha, and restore-keys does the real matching. A pull request never hits the exact key, falls back to the ${{ runner.os }}-ccache- prefix, and restores the newest main-branch cache. The save step runs only on pushes to main, which keeps the cache count low and the entries authoritative.
CCACHE_MAXSIZE caps what you store and ship. A 2GB ceiling is enough for most mid-size codebases, keeps restore time short, and bounds the cache storage line on the bill. Raise it if ccache -s shows evictions on warm runs. Cache entries expire after 7 days without use.
Dependencies cache separately. If the project pulls third-party code through vcpkg or Conan, persist ~/.cache/vcpkg or the Conan home directory with another WarpBuilds/cache@v1 step keyed on the manifest lockfile. Rebuilding Boost from source on every push is the single most expensive habit a C++ pipeline can have.
For aarch64 targets, swap the label to an ARM64 size such as warp-ubuntu-latest-arm64-8x and the same configuration compiles natively with no cross toolchain. The Linux ARM64 runner page lists the available sizes and rates.
Sizing
The Linux x64 catalog, with per-minute rates from the WarpBuild pricing page:
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 |
| warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 |
Every size carries 4 GB of memory per vCPU. That ratio is the number to hold in your head when picking -j, because a C++ compile job is a memory consumer as much as a CPU consumer. Setting --parallel equal to the vCPU count budgets about 4 GB per compile process on any of these machines, which covers ordinary translation units comfortably and template-heavy ones adequately.
warp-ubuntu-latest-x64-8x with --parallel 8 is the starting point for a mid-size codebase. Eight concurrent compile jobs inside a 32 GB ceiling leaves headroom for the linker and the build system itself. This is the size to measure on before paying for more.
warp-ubuntu-latest-x64-16x with --parallel 16 fits large codebases and template-heavy ones. Instantiation-dense translation units, the kind that include Eigen or Boost.Spirit, can peak at 2 to 4 GB per compile process, and 16 jobs at that peak still fit under the 64 GB ceiling. Sanitizer builds also belong here: ASan instrumentation grows both compile memory and test memory, and the wider ceiling absorbs it.
warp-ubuntu-latest-x64-32x with --parallel 32 pays off on wide dependency graphs, big generated-code trees, and release matrices under deadline. At 128 GB the ceiling stops being the constraint for compilation. Check the CPU chart before moving up: late in a build the graph narrows to a few final targets plus their links, and idle cores at 32 vCPUs cost the same as busy ones.
Two refinements to plain -j:
Cap link parallelism separately. Linking a binary with full debug info can consume many gigabytes on its own, and a build that schedules four such links concurrently can hit the memory ceiling even though compilation never did. With the Ninja generator, CMake job pools express this directly: configure with -DCMAKE_JOB_POOLS=link=2 -DCMAKE_JOB_POOL_LINK=link to allow at most two concurrent links while compilation keeps using every core.
Watch for OOM kills before upsizing blindly. A compile step that dies with internal compiler error: Killed is the kernel reclaiming memory. Either lower --parallel below the vCPU count or move up one size to raise the ceiling. The 150GB SSD is rarely the limit, but a build tree plus a large ccache plus a vcpkg tree can approach it on monorepos, and df -h in a debug step settles the question quickly.
Worked cost model
GitHub publishes list prices for its hosted runners: the standard Linux runner meters at $0.006 per minute, and Linux larger runners meter at $0.012 for 4 vCPU, $0.022 for 8 vCPU, $0.042 for 16 vCPU, and $0.082 for 32 vCPU. Rates are from the GitHub Actions billing documentation and github.com/pricing, checked on 2026-08-13.
Take a C++ project whose pull request pipeline consumes 25,000 runner-minutes per month on 16 vCPU machines, storing 20 GB of ccache and dependency cache and performing 8,000 cache operations:
| Line item | Rate | Volume | Monthly cost |
|---|---|---|---|
| GitHub-hosted Linux 16 vCPU larger runner | $0.042 per minute | 25,000 minutes | $1,050.00 |
| warp-ubuntu-latest-x64-16x | $0.032 per minute | 25,000 minutes | $800.00 |
| WarpBuild cache storage | $0.20 per GB-month | 20 GB | $4.00 |
| WarpBuild cache operations | $0.0001 per operation | 8,000 operations | $0.80 |
The WarpBuild total is $804.80 against $1,050.00 on GitHub-hosted larger runners for the same minutes, a difference of $245.20 per month. As a standalone rate comparison: 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. The model holds minutes equal on both sides. The full breakdown of GitHub's larger runner tiers is in the guide to GitHub Actions larger runner costs.
Full rates for every size and platform are on the pricing page.
Bottlenecks
Four failure modes account for most slow C++ pipelines on GitHub Actions.
Cold compiler cache. Every runner starts as a fresh VM, so without a persisted CCACHE_DIR each job compiles the entire tree from scratch, including the vast majority of translation units the commit never touched. The fix is the restore and save pair above. Two things quietly invalidate a warm cache: a compiler upgrade, since ccache keys on the compiler, and flag drift between jobs, since a -O2 object never satisfies a -O3 request. Pin the toolchain and keep flags identical across jobs that share a key.
Link step serialization. The linker is one process per binary regardless of vCPU count, so a pipeline that compiles in two minutes can still spend five inside ld. Mitigations stack: switch to mold or lld through -DCMAKE_LINKER_TYPE=MOLD or the equivalent flags, split debug info out of the link with -gsplit-dwarf, and consolidate small test executables so there are fewer binaries to link. Memory helps too, which is a reason the 64 GB ceiling on the 16x size matters for binaries with heavy debug info.
Header-heavy translation units. When a common header pulls in a template library, every including translation unit pays the parse and instantiation cost again. Precompiled headers through CMake's target_precompile_headers cut the repeated parsing, and unity builds through CMAKE_UNITY_BUILD=ON amortize it across batched sources. One interaction to know: ccache needs CCACHE_SLOPPINESS=pch_defines,time_macros set before compiles that consume a precompiled header will cache. Include hygiene is the durable fix, and tools such as include-what-you-use keep the graph from regrowing.
Cross-compilation matrices. A matrix that builds x64 and aarch64 from one x64 job pays twice: the cross toolchain forfeits the native ccache history, and any tests that run under emulation crawl. The cheaper shape is one native job per architecture, with warp-ubuntu-latest-arm64-8x compiling and testing aarch64 directly at $0.012 per minute. The Linux ARM64 runner page covers the native ARM64 setup in detail.
Telling these apart is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so one busy core during a long link looks visibly different from all cores saturated through compilation, and a memory ceiling shows up as a plateau before an OOM kill. For a stuck or misbehaving job, the Action Debugger pauses the workflow and opens an SSH session on the live runner, which is the fastest way to inspect a hung compile or read ccache -s in place. Workflow-level tactics, including job fan-out and test sharding, are covered in the guide to speeding up GitHub Actions, and the same bottleneck analysis applies with different weights on the fast Rust builds page.
Proof
The largest public C++ evidence is Bitcoin Core. The bitcoin/bitcoin GitHub Actions workflow routes its Linux jobs across warp- labels sized to the job: lint on warp-ubuntu-latest-x64-2x, the macOS cross builds on warp-ubuntu-latest-x64-4x, the TSan and BSD cross-compilation matrix on warp-ubuntu-latest-x64-8x with the ASan job pinned to warp-ubuntu-2404-x64-8x, and the fuzz and MSan jobs on warp-ubuntu-latest-x64-16x, with ccache directories restored and saved between runs (checked on 2026-08-13). The workflow file is open to read, size choices and cache keys included.
FAQ
Which WarpBuild runner size should a C++ project start with?
Start on warp-ubuntu-latest-x64-8x at $0.016 per minute and build with --parallel 8. Move the build job to warp-ubuntu-latest-x64-16x when the CPU chart in WarpBuild's CI observability shows all 8 vCPUs saturated through compilation, and keep clang-format and other light checks on warp-ubuntu-latest-x64-4x.
Does ccache work with WarpBuild cache?
Yes. Point CCACHE_DIR at a workspace path, restore and save that directory with WarpBuilds/cache@v1, and set CMAKE_C_COMPILER_LAUNCHER and CMAKE_CXX_COMPILER_LAUNCHER to ccache. WarpBuilds/cache is a drop-in replacement for actions/cache@v4.
Can I build C++ for ARM64 on GitHub Actions with WarpBuild?
Yes. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. Labels such as warp-ubuntu-latest-arm64-8x compile aarch64 code natively, with rates starting at $0.003 per minute for the 2 vCPU size.
Is WarpBuild SOC 2 compliant?
The audit evidence is published at trust.warpbuild.com.
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.