---
title: CI setup
description: Use Kache in GitHub Actions or a shell-based CI job
---
## GitHub Actions
The official action installs Kache, configures the Rust wrapper, and can persist the local store through GitHub's cache service:
```yaml title=".github/workflows/ci.yml"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: kunobi-ninja/kache-action@v1
- run: cargo build --locked
```
See the [kache-action repository](https://github.com/kunobi-ninja/kache-action) for its current input reference and release notes.
For S3-compatible storage:
```yaml
- uses: kunobi-ninja/kache-action@v1
with:
s3-bucket: my-build-cache
s3-region: eu-west-1
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY_ID }}
s3-secret-access-key: ${{ secrets.S3_SECRET_ACCESS_KEY }}
```
Add `s3-endpoint` for R2, Ceph, or MinIO. Prefer workload identity or IAM roles over long-lived access keys when the runner supports them.
## Who may write
Treat write credentials for any remote as trusted build credentials. The permissions they grant depend on the backend.
On GitHub Actions and GitLab CI, Kache suppresses remote writes unless the job is a **push to a protected branch**. Pull requests, merge requests, tags, unprotected branches, `workflow_dispatch`, and scheduled runs are read-only even if the job has a write credential. `KACHE_REMOTE_READONLY=0` does not turn that off.
A laptop or other non-CI process is left as configured, so `kache sync --push` still works locally. Set `remote_readonly = true` under `[cache]` there when you want reads without writes. `KACHE_REMOTE_READONLY=1` affects only the process that reads it: a daemon that a build starts drops it.
```yaml
# Optional: make the read-only intent explicit in pull-request jobs.
env:
KACHE_REMOTE_READONLY: "1"
```
GitHub withholds secrets and OIDC tokens from fork pull-request jobs, so those runs cannot publish unless you also pass a write credential another way. Same-repository pull requests do receive secrets; the CI policy above is what stops them writing.
Pull-request jobs can still restore entries a trusted branch already published.
### A prefix for pull requests
OCI pull requests stay read-only even with `pull_request_prefix`. Registry
permissions cover the repository, so tag prefixes cannot isolate writes.
See [OCI setup](/docs/remote-cache/oci-setup). The example below uses S3.
Read-only pull requests never reuse their own work: each push recompiles what the pull request changed, even when the previous push built it. Give pull requests a prefix of their own and they can:
```toml
[cache.remote]
type = "s3"
bucket = "acme-build-cache"
prefix = "artifacts"
pull_request_prefix = "artifacts-pr"
```
`KACHE_PULL_REQUEST_PREFIX` sets the same value. In a pull request job (a GitHub `pull_request` event or a GitLab merge request pipeline), Kache then:
- reads the base prefix first and the pull request prefix for what the base lacks;
- writes entries, build manifests, shards and prediction rows only under the pull request prefix.
Every other job ignores the setting: protected-branch pushes never read the pull request prefix, and tags, unprotected branches and `pull_request_target` jobs stay read-only. The pull request prefix must differ from `prefix`, and neither may contain the other; otherwise the job stays read-only and Kache logs why.
A pull request job with a prefix runs its own daemon. Its socket and upload queue carry a name derived from the prefix (`daemon-pr-<hash>.sock`, `upload-queue-pr-<hash>`), so it never hands uploads to a protected-branch job's daemon or reads through one. It still must not share a local store with trusted jobs: an artifact it compiles there would be a local hit for the next trusted build. Keep pull request jobs off the persistent disk described in [Ephemeral and persistent runners](#ephemeral-and-persistent-runners).
Kache's policy is not the boundary; the bucket policy is. Give the role that pull request jobs assume read access to both prefixes and write access to the pull request prefix only:
```json
{
"Statement": [
{ "Effect": "Allow", "Action": "s3:GetObject",
"Resource": ["arn:aws:s3:::acme-build-cache/artifacts/*",
"arn:aws:s3:::acme-build-cache/artifacts-pr/*"] },
{ "Effect": "Allow", "Action": "s3:PutObject",
"Resource": "arn:aws:s3:::acme-build-cache/artifacts-pr/*" }
]
}
```
Expire the pull request prefix with a lifecycle rule. One prefix is shared by all pull requests of the repository, so someone who can open a pull request can affect another pull request's builds, but never `main`, a tag or a release.
In a `container:` job, the runner mounts `HOME` and `runner.temp` separately from the workspace, and a hardlink cannot cross a mount. Leave `cache-dir` unset and the action puts the cache on the workspace's mount. A `cache-dir` on another mount still works, but kache copies every artifact into `target/`.
C and C++ objects publish through the same remote as Rust. A daemon-backed compile restores them on miss; `kache sync --pull --all` also warms them in the local store. clang-cl debug objects stay on the machine and are not published.
## Other CI systems
Install Kache and write the remote to its config file before the first build. The background daemon uploads what the build compiles. A daemon that a build starts drops `KACHE_S3_BUCKET`, `KACHE_S3_REGION`, `KACHE_S3_ENDPOINT` and the other [remote settings](/docs/daemon/lifecycle#updates-and-config-changes) from its environment, so the remote has to be in the file. CI has no terminal to answer prompts, so pass `--yes`:
```bash
mkdir -p ~/.config/kache
printf '[cache.remote]\ntype = "s3"\nbucket = "my-build-cache"\nregion = "eu-west-1"\n' > ~/.config/kache/config.toml
kache init --yes
cargo build --locked
kache daemon stop
```
`~/.config/kache/config.toml` is the default path. If the job sets `KACHE_CONFIG` or `XDG_CONFIG_HOME`, write the file there instead.
`kache daemon stop` lets the daemon finish its queued uploads before it exits, so run it before the runner goes away. `kache sync --push` does not replace it: inside a Cargo workspace it uploads only the workspace's own crates, without their dependencies. The daemon keeps credentials from the environment, so they can stay in `KACHE_S3_ACCESS_KEY` and `KACHE_S3_SECRET_KEY`, the runner's secret store, or the standard cloud identity chain. Use `--allow-partial` only when cache failure must not fail the job.
## Ephemeral and persistent runners
Speculative prefetch is intended for empty runners. A persistent self-hosted runner may already have the needed entries locally. Measure both modes and disable speculation when it adds work:
```toml
[cache]
prefetch_enabled = false
```
This keeps exact remote reads and uploads.
Concurrent jobs may share a persistent local store, but each job should have a distinct `KACHE_RUNTIME_DIR` for sockets, locks, event logs, and session state. kache-action already gives each job its own runtime directory. For a trusted disk that persists across jobs, set `node-cache: true` and `cache-dir` on the action.
On a trusted builder that keeps a disk across jobs, point `KACHE_CACHE_DIR` at that disk and set `KACHE_TRUST_DOMAIN` to one label for the trust domain (for example `github.kunobi-ninja.kache`). Kache stores under `$KACHE_CACHE_DIR/<label>/`. Keep `KACHE_RUNTIME_DIR` on the job's scratch disk so sockets die with the pod. Do not mount that disk or export `KACHE_TRUST_DOMAIN` on public or fork jobs. An unusable label leaves `KACHE_CACHE_DIR` unchanged.
## Progress and reports
Kache is quiet in compiler-wrapper mode by default because Cargo keeps a compiler's stderr and replays it when the unit is fresh. `KACHE_PROGRESS` turns on progress lines. From a terminal they go straight to it, out of Cargo's reach. In CI there is no terminal, so they go to stderr, and a later Cargo command in the same job can replay a line for a unit Kache did not run again.
```yaml
env:
KACHE_PROGRESS: hits # hits only
# KACHE_PROGRESS: verbose # hits, dups, misses, and long-compile heartbeats
```
Create a machine-readable summary after the build:
```bash
kache stats --format json --output kache-report.json
kache stats
```
## Coverage and uncached steps
Coverage instrumentation uses separate, path-local keys. If you do not want coverage artifacts in Kache:
```yaml
- run: cargo llvm-cov
env:
KACHE_DISABLED: "1"
```
Unset `KACHE_DISABLED` to enable caching again. Only `1` and `true` disable it.