Rust Incremental Compilation in GitHub Actions

Cargo incremental state lives in target and dies with the runner. Set CARGO_INCREMENTAL=0 on stateless GitHub Actions jobs, or keep the target directory warm.

Rust incremental compilation rarely fires on GitHub Actions because cargo keeps its incremental state in the target directory, and every job starts on a clean VM that has neither that directory nor the file mtimes cargo fingerprints against. Two configurations work: set CARGO_INCREMENTAL=0 and cache compiled dependencies instead, or run the job on a machine that already holds the target directory from the previous run.

This guide covers the flags that control incremental compilation, when each configuration pays, a workflow that pairs an incremental profile with a persistent target directory, and a time model with its assumptions written down.

Diagnosis

Incremental compilation writes a dependency graph and a set of cached query results into target/<profile>/incremental/, one session directory per crate. On the next compile, rustc reloads that graph and recompiles only the query results that the edit invalidated. Cargo enables it for workspace members and path dependencies only, so crates pulled from a registry compile without it regardless of your settings (Cargo profile reference).

Three things break that on a stateless runner.

The directory is deleted with the machine. Nothing reloads, so rustc starts a fresh session and compiles the crate from scratch.

Restoring the directory from a cache action does not bring it back to life. Cargo decides whether a path package is fresh by comparing recorded mtimes against the files on disk, and actions/checkout writes a new working tree whose mtimes are all newer than the artifacts you restored. Cargo marks the crate dirty and recompiles it, discarding the incremental session it just spent minutes downloading. The answer on why cargo rebuilds everything works through that fingerprint check.

The data is also the most expensive part of the transfer. Incremental sessions commonly account for more of a debug target directory than the compiled artifacts do, and both the upload and the download are billed as runner minutes. This is why the standard Rust caching actions set CARGO_INCREMENTAL to 0 and exclude target/*/incremental from what they save.

ControlDefaultScope
CARGO_INCREMENTAL env varunsetWins over every profile setting when set to 0 or 1 (Cargo environment variables)
[profile.dev] incrementaltruedev and test profiles
[profile.release] incrementalfalserelease and bench profiles
--config profile.dev.incremental=truenoneOne cargo invocation
RUSTC_WRAPPER set to sccacheunsetsccache does not cache incrementally compiled crates, so the two mechanisms exclude each other

Fix

Pick the regime that matches where the target directory lives.

On a stateless runner with a cache action, set CARGO_INCREMENTAL=0, cache ~/.cargo/registry, ~/.cargo/git, and the compiled artifacts under target, and skip target/*/incremental. The reuse you get is dependency compilation, which is the larger half of the wall clock on most workspaces, and the workspace crates recompile every run. Sizing that path is covered on the Rust builds on GitHub Actions page. The guide to sccache on GitHub Actions covers the object-cache route for the same shape of job, and the hub on persistent caches for GitHub Actions compares the storage options behind both.

To get real incremental compilation, keep the target directory on the machine. A WarpBuild snapshot runner captures the runner VM mid-workflow, and later jobs boot from that image with the target directory, the checked-out tree, and its mtimes in place. Because actions/checkout reuses an existing repository whose remote matches, unchanged sources keep the mtimes the previous run left on them, and cargo finds most workspace crates fresh. The same pattern applied to C and C++ trees is in the guide to incremental builds on GitHub Actions.

Four rules keep that configuration honest.

Set CARGO_TARGET_DIR to a fixed absolute path outside the workspace. Incremental artifacts record absolute paths, so the directory has to reappear at the same place on every boot, and a cleanup step that runs git clean -ffdx would otherwise delete it.

Keep it out of /tmp. That directory is cleared on reboot and a snapshot boot is a reboot.

Write snapshots from the default branch only, so a pull request cannot poison the image other pull requests boot from.

Check the platform. Snapshots are supported on WarpBuild Cloud Ubuntu runners only. A snapshot label elsewhere is silently ignored, so the job runs with no warning and no change in duration. Snapshot runners sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the product surface, and the CPU chart in observability is the fastest way to confirm a run went incremental.

Configuration

A CI profile that turns incremental on explicitly and trims debug info to line tables, which is where most of the link time goes on a debug build:

[profile.ci]
inherits = "dev"
incremental = true
debug = 1

The workflow. Pushes to main boot from the base image and write the alias; pull requests boot from it.

name: rust

on:
  push:
    branches: [main]
  pull_request:

env:
  CARGO_TERM_COLOR: always
  CARGO_INCREMENTAL: "1"
  CARGO_TARGET_DIR: /home/runner/cargo-target

jobs:
  test:
    runs-on: >-
      ${{ github.ref == 'refs/heads/main'
        && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
        || 'warp-ubuntu-latest-x64-8x;snapshot.key=api-rust-main' }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v5

      - name: Restore cargo home
        uses: WarpBuilds/cache@v1
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
          key: cargo-home-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: cargo-home-

      - name: Build and test
        run: cargo test --workspace --profile ci

      - name: Cleanup credentials
        if: github.ref == 'refs/heads/main'
        run: rm -rf $HOME/.ssh $HOME/.aws

      - name: Save snapshot
        if: github.ref == 'refs/heads/main'
        uses: WarpBuilds/snapshot-save@v1
        with:
          alias: api-rust-main
          fail-on-error: true
          wait-timeout-minutes: 45

Both the env var and the profile setting say the same thing on purpose. CARGO_INCREMENTAL takes precedence, so a profile that reads incremental = true while the environment holds 0 compiles non-incrementally and gives no warning.

The cargo home stays in a cache action rather than the image, documented on the WarpBuild caching page, so a Cargo.lock bump invalidates one entry instead of the whole image. The cleanup step removes credentials but leaves the working tree, because the tree and its mtimes are the thing the image exists to carry. On a public repository anyone who can open a pull request can boot from that alias, so keep the pattern to private repositories or to trees you are willing to publish.

Cost or Time Model

Assumptions, so you can substitute your own step timings:

  • A workspace of 28 first-party crates and 640 registry dependencies, with a 9.4 GB target directory at the ci profile, of which 5.3 GB is incremental session data.
  • Runner warp-ubuntu-latest-x64-8x, 8 vCPU and 32 GB, at $0.016 per minute from the pricing page.
  • Change size: a pull request touching 3 files across 2 workspace crates, with no dependency change.
  • 500 pull request runs per month against one alias.
StageClean VM, cache action, CARGO_INCREMENTAL=0Snapshot boot, warm target, CARGO_INCREMENTAL=1
Boot0.25 min1.00 min
Checkout0.35 min0.15 min
Restore cargo home and target2.90 min0.00 min
Fingerprint scan0.40 min0.35 min
Compile workspace crates6.10 min1.20 min
Link test binaries1.30 min0.90 min
Save state, amortized1.10 min0.20 min
Total per run12.40 min3.80 min

The compile row carries the difference. In the left column cargo recompiles all 28 workspace crates because checkout rewrote their mtimes; in the right column it recompiles the 2 the diff touched and reloads the dependency graph for the rest.

Priced out across 500 runs:

LineClean VM plus cache actionSnapshot runner
Runner minutes6,200 at $0.016 = $99.201,900 at $0.016 = $30.40
Cache storage4.1 GB at $0.20 per GB-month = $0.82included in the cargo home entry
Cache operations1,000 at $0.0001 = $0.101,000 at $0.0001 = $0.10
Snapshot restores$0.00500 at $0.04 = $20.00
Snapshot storage$0.00720 snapshot-hours at $0.025 = $18.00
Monthly total$100.12$68.50

The snapshot fees are flat at $38.00 per alias per month, so the trade improves with run count and with runner size. The same step timings on warp-ubuntu-latest-x64-16x at $0.032 per minute put the clean VM path at $199.32 and the snapshot path at $98.90, while at 60 runs per month the flat fees swallow the saving and CARGO_INCREMENTAL=0 on a stateless runner is the cheaper answer.

Against GitHub-hosted list prices for the same shapes, from the GitHub Actions billing reference, checked on 2026-08-13:

ShapeGitHub-hosted per minuteWarpBuild labelWarpBuild per minute
8 vCPU, 32 GB$0.022warp-ubuntu-latest-x64-8x$0.016
16 vCPU, 64 GB$0.042warp-ubuntu-latest-x64-16x$0.032

At the 8 vCPU shape that is 27 percent lower list price, and the 6,200 clean VM minutes cost $136.40 on the GitHub-hosted larger runner against $99.20 on warp-ubuntu-latest-x64-8x. The model holds step timings identical across every column so that only the mechanism and the list price move.

FAQ

Should I set CARGO_INCREMENTAL=0 in GitHub Actions?

Yes on a stateless runner with a cache action. The incremental directory is the largest part of a target directory, and every byte of it is uploaded and downloaded as billed runner minutes, while cargo recompiles the workspace crates anyway once actions/checkout rewrites their mtimes. Set CARGO_INCREMENTAL=1 only when the target directory stays on the machine between runs.

Does incremental compilation apply to my registry dependencies?

No. Cargo passes the incremental flag for workspace members and path dependencies only, so crates from crates.io compile without it whatever CARGO_INCREMENTAL says. Reuse for those comes from caching the compiled artifacts in the target directory and the cargo home.

Can I use sccache and incremental compilation together?

No. sccache does not cache incrementally compiled crates, so a job that sets RUSTC_WRAPPER to sccache should keep CARGO_INCREMENTAL at 0. Pick one mechanism per job. Use sccache for wide dependency graphs on stateless runners, or incremental compilation for a warm target directory on a persistent one.

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.