Cache Cargo GitHub Actions builds with rust-cache, and its post step
To cache Cargo GitHub Actions builds the answer is Swatinem/rust-cache, and the two things worth understanding about it are both invisible from the workflow file: it restores on a partial key when the exact one misses, and before it saves anything it deletes most of what your build just produced. That deletion is why the post step sometimes takes longer than the build.

A Rust CI job is slow for one reason: everything compiles from source, including hundreds of dependency crates that have not changed since the last run. Caching the registry and the target directory is the highest-leverage change available, and Swatinem/rust-cache is the action that does it with keys and pruning nobody has to maintain.
It is also an action with strong opinions that it does not announce in its README summary. It exports CARGO_INCREMENTAL=0 into your job. It refuses to save on a failed build unless you ask it to. It deliberately does not cache your own workspace crates by default. And its post step runs four cleanup passes before the upload. Each of those is defensible and each surprises somebody once a quarter.
Two keys, not one, and what that buys
The action builds a cache key and a restore key, and hands both to the toolkit. The cache key is the full one: a v0-rust prefix, then either your shared-key or your key plus the job name, then the runner OS and architecture, then a hash of the Rust environment, then a hash of your manifests and lockfile. The restore key is the same string with the last piece removed, so it matches any recent cache for the same job, toolchain and environment regardless of which dependencies were in it.
That is the difference between this action and a cache on an exact key. When you add one dependency, the full key misses and the restore key hits, and you get last run's registry and last run's compiled target back. Cargo then builds the delta. The action notices the partial match, cleans the target directory of anything stale before the build starts, and marks the entry for re-saving at the end.
The lockfile hash is more careful than a plain file hash, which matters on a workspace. The action parses Cargo.toml and Cargo.lock, rewrites your own package version to 0.0.0, blanks the path of any path dependency, and drops lock entries with no source or checksum. The effect is that bumping your own crate version does not invalidate the whole cache, which a naive hashFiles over those files would do on every release commit.
| Key component | In the cache key | In the restore key |
|---|---|---|
v0-rust prefix, or your prefix-key | Yes | Yes |
shared-key, or key plus the job id | Yes | Yes |
| Runner OS and CPU architecture | Yes | Yes |
| Rust versions and the CARGO, CC, CFLAGS, CXX, CMAKE and RUST environment variables | Yes | Yes |
Hash of parsed manifests and Cargo.lock | Yes | No |
What actually gets cached
Five paths by default, and one of them is conditional. The registry and the git checkouts under CARGO_HOME are always included. The bin directory and the two crate manifests beside it are included unless you set cache-bin: false, which is how a cargo tool you installed in an earlier step survives to the next run. Each workspace target directory is included unless you set cache-targets: false, which leaves you with a registry cache and a full recompile.
What is deliberately excluded is your own code. cache-all-crates defaults to false, so only crates your workspace depends on are kept in the registry, and cache-workspace-crates defaults to false, so the compiled artifacts of your own workspace members are removed from target before the save. That is the right default for correctness and it is the thing people turn off when they measure the post step, because it is a large part of what the cleanup is doing.
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
# one entry shared by every job in the workflow instead of one per job
shared-key: ci
# keep an entry from a red build, so a flaky suite still warms the cache
cache-on-failure: true
# only the default branch writes; feature branches restore
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo test --allWhy the post step takes minutes
Before it uploads anything, the save step runs four passes in order: it cleans each workspace target directory, cleans the cargo registry, cleans cargo/bin, and cleans the cargo git cache. Only then does it archive and upload. On a small crate you will never notice. On a workspace with a few hundred dependencies the cleaning is a large number of filesystem operations over a directory tree that is itself a few gigabytes, and it can take longer than the build it is caching.
The cleaning is not gratuitous. A Cargo target directory accumulates every artifact from every version of every dependency it has ever compiled, and caching it raw would produce an entry that grows without limit and evicts everything else in the repository. The passes keep the build, .fingerprint and deps directories and, inside them, only the entries belonging to packages the current build actually used.
If your post step is the thing hurting, the levers are in this order: set cache-targets: false to cache the registry alone, which removes the largest cleaning pass entirely; set save-if so only one branch pays for the save; or split the workspace so each job caches a smaller target directory. Turning the action off is rarely the answer, because a cold Rust build is nearly always worse than a slow save.
... Cleaning /home/runner/actions-runner/_work/rsmono/rsmono/target ...
... Cleaning cargo registry (cache-all-crates: false) ...
... Cleaning cargo/bin ...
... Cleaning cargo git cache ...
... Saving cache ...
/usr/bin/tar --posix -cf cache.tzst --exclude cache.tzst -P -C /home/runner/actions-runner/_work/rsmono/rsmono --files-from manifest.txt --use-compress-program zstdmtThe environment variable it sets for you
The restore step exports CARGO_INCREMENTAL=0 into the rest of the job. That disables Cargo incremental compilation, and it is correct for CI for two reasons: incremental artifacts are large, so they would bloat the cache badly, and they are useless across runners because the next job is a fresh machine anyway.
It has one visible consequence worth knowing. CARGO_INCREMENTAL is one of the variables hashed into the cache key, along with everything else starting with CARGO, CC, CFLAGS, CXX, CMAKE or RUST. So a workflow that sets RUSTFLAGS differently on two jobs has two different cache keys by design, and a workflow that changes RUSTFLAGS at all invalidates its cache. That is the correct behavior, and it is also the reason a one-character change to a lint flag can produce a completely cold build.
If you need extra variables in the key, env-vars takes a space-separated list of additional prefixes. If you need fewer, there is no way to remove the defaults, and the right fix is to stop setting a variable that differs per run.
What to check, in order
- Read the Cache Configuration group in the restore step. It prints the cache key, the restore key, the exact environment variables considered and the lockfiles considered, which answers most questions without changing anything.
- Look for
full match: falsein that step. That is a partial restore working as designed, not a problem, and it means the delta is being compiled rather than the whole tree. - If nothing ever restores on a feature branch, check that something runs on the default branch. Cache scoping means a branch can only read its own entries, the default branch and, on a pull request, its base.
- If the post step is slow, time it against the build before you change anything, then try
cache-targets: falseand compare. Caching the registry alone is often most of the win for a fraction of the save. - If entries seem to vanish, add up what you are storing. A Rust target cache is easily several gigabytes and the repository allowance is 10 GB, so two or three branch-scoped entries evict each other. GitHub Actions cache size limit has the eviction rules.
Frequently asked questions
Why is the rust-cache post job cleanup so slow?
cargo/bin and the cargo git cache, then archives what is left. On a workspace with hundreds of dependencies that is a very large number of filesystem operations over a multi-gigabyte tree, and it can exceed the build time. Setting cache-targets: false removes the largest pass.What does Swatinem/rust-cache actually cache?
CARGO_HOME, the CARGO_HOME/bin directory with its two crate manifests unless cache-bin is false, and each workspace target directory unless cache-targets is false. Your own workspace crates are pruned out of both the registry and the target directory by default, which is what cache-all-crates and cache-workspace-crates control.Does rust-cache save the cache when the build fails?
success() unless cache-on-failure is true, in which case it also runs when the job failed. Turning it on is usually right for a repository with a flaky suite, because otherwise half your runs contribute nothing and the cache stays cold much longer than the dependency churn alone would explain.Why did my Rust cache go cold after a RUSTFLAGS change?
RUSTFLAGS by one character produces a different key and a different restore key. That is deliberate: artifacts compiled under different flags are not interchangeable, so reusing them would be worse than recompiling.How do I share one Rust cache across several jobs?
shared-key to the same value on each job. By default the key includes the job id, so a build job and a clippy job keep separate entries of the same dependency tree and compete for the same repository allowance. A shared key replaces the job id in the prefix, so the second job restores what the first one saved.Related guides
References
- Swatinem/rust-cache: key and path construction in src/config.ts (verified 2026-09-21)
- Swatinem/rust-cache: the cleaning passes in src/save.ts and src/cleanup.ts (verified 2026-09-21)
- Swatinem/rust-cache#215, the post step log quoted on this page (verified 2026-09-21)
- GitHub Docs: dependency caching reference, key matching and cache scope (verified 2026-09-21)
- The Cargo Book
- GitHub Actions documentation