Build caches¶
A new sandbox used to install every dependency and compile everything from nothing. On a big repo that is minutes of every job and every session before the agent does anything useful, and an agent short on time skips the build and the tests instead. hangar now keeps two kinds of cache across sandboxes, per repo:
- Dependency caches: the package managers' caches and the directories they install into (
~/.npm,node_modules,~/.cache/uv,.venv,~/.cargo,target, the Go module and build caches, Maven, Gradle). They are restored after the clone, in the background: the sandbox is ready at once, and the agent is told the caches are warming. - Remote compile caches, which hangar serves itself in the protocols the tools already speak: sccache's WebDAV
backend, Bazel's HTTP remote cache, and the Turborepo remote cache API. Sandboxes come with the variables and the
~/.bazelrclines that point the tools there. Nothing in the repo has to change.
Both sit on top of repo builds (#93). A build is an image of the default branch's checkout after
install, so a sandbox started from one already has node_modules. What a build doesn't have, the caches cover:
whatever lives in $HOME, a branch whose lockfile has moved on, a harness variant or provider that runs without
builds, and compile output.
Who may write what is the part that matters most. Members, branches and untrusted repos share this infrastructure, so a cache written from one sandbox must never feed another person's build. The rules are below, and the threat model is in security/build-caches.md.
Turning it on¶
Caches are on by default (HANGAR_BUILD_CACHES=true), and they need the git gateway. Sandboxes reach the caches at
/cache on the gateway's port, with the grant the gateway gave them (hgs_…), so they need HANGAR_GATEWAY_URL.
A sandbox without a grant gets no caches: that is a direct-mode sandbox, or one whose repo isn't on a host the gateway
serves. Jobs and sessions always have a grant when the gateway is configured.
| Setting | Default | |
|---|---|---|
HANGAR_BUILD_CACHES |
true |
Off: no /cache routes, nothing restored, no variables set. |
HANGAR_BUILD_CACHE_STORE |
the session bucket's caches/ prefix, else <data_dir>/caches |
gs://bucket[/prefix] or a directory. |
HANGAR_BUILD_CACHE_MAX_GB |
100 in a bucket, 10 on the data volume | All repos together. |
HANGAR_BUILD_CACHE_REPO_MAX_GB |
20 | Per repo. |
HANGAR_BUILD_CACHE_RETENTION_DAYS |
14 | An entry unused this long goes, in the nightly pass. |
HANGAR_BUILD_CACHE_PRIVATE_DAYS |
3 | The same for an entry in a session's private layer. |
HANGAR_BUILD_CACHE_PRIVATE_WRITES |
true |
Off: sessions and jobs only read. |
HANGAR_BUILD_CACHE_OBJECT_MAX_MB |
4096 | The largest single object (a dependency archive, a compile output). |
HANGAR_BUILD_CACHE_TRANSFER_MB |
256 | Cache bytes held in the control plane's memory at once: what uploads buffer and seal (up to four times a chunk each, or four times their length when it is smaller) and the chunks downloads haven't sent yet. Requests past it wait; an upload that sends nothing for 60 s is cut off. |
HANGAR_BUILD_CACHE_WAIT_MINUTES |
10 | How long a repo build waits for its restore before install. |
On GKE the session bucket is where caches go, and hangar's service account can already write there. On Docker,
size the data volume for HANGAR_BUILD_CACHE_MAX_GB.
Who writes, who reads¶
Each repo's caches have layers:
- The shared (trusted) layer, one per environment. Only hangar's own repo builds write it. A repo build runs the
installof the default branch, or of the branch the environment names, in a sandbox hangar created for that build. hangar's build step saves the dependency caches after a successfulinstall(hangar-cache save), and compile caches written duringinstall(aninstallthat runscargo buildwith sccache, for example) land there too. The layer belongs to the repo's builds in one environment, and only that environment's sandboxes read it. An environment's image andsetuprun in its builds beforeinstall, and members create environments, so a build in one member's environment never writes what sandboxes of another environment read. A build with build secrets has every upload scanned for them from the start ofinstall, and can't store what the scan can't look into (zstd, zip and the like), so its sccache and turbo entries aren't kept (security/build-caches.md). - A private layer per session (or sandbox) and branch. A session's or job's sandbox reads its own private layer
first and the shared layer second. Its writes, from sccache, Bazel, turbo or
hangar-cache save, go to its private layer. Nothing else ever reads that layer: not another session, not another person, and not a build. Nothing is promoted from it to the shared layer. The layer belongs to the session, so the session's next sandbox (after a suspend, say) finds what it wrote. WithHANGAR_BUILD_CACHE_PRIVATE_WRITES=false, sessions and jobs only read: sccache runs withSCCACHE_WEBDAV_RW_MODE=READ_ONLY, turbo withTURBO_CACHE=local:rw,remote:r, and Bazel with--remote_upload_local_results=false. - No repo reads another's. The grant names one repo, and that repo is the only one the sandbox can read.
Agent-led setup's validation sandboxes run an agent-written branch, so they count as untrusted like any session. A
sandbox someone creates with a hangar.build label is not a build either. hangar trusts a sandbox as a build only
when the build it names is running, is of the same repo, and created that very sandbox.
Dependency caches¶
| Kind | What | Keyed by |
|---|---|---|
npm |
~/.npm |
package-lock.json, npm-shrinkwrap.json |
pnpm |
~/.local/share/pnpm/store |
pnpm-lock.yaml |
yarn |
~/.cache/yarn, ~/.yarn/berry/cache |
yarn.lock |
node_modules |
node_modules in the checkout |
any of the Node lockfiles, and node --version |
uv |
~/.cache/uv (UV_CACHE_DIR) |
uv.lock |
pip |
~/.cache/pip (PIP_CACHE_DIR) |
requirements.txt, poetry.lock, Pipfile.lock |
venv |
.venv in the checkout |
the Python lockfiles, and python3 --version |
cargo |
~/.cargo/registry, ~/.cargo/git |
Cargo.lock |
cargo-target |
target in the checkout |
Cargo.lock, and rustc --version |
go-mod |
~/go/pkg/mod (GOMODCACHE) |
go.sum |
go-build |
~/.cache/go-build (GOCACHE) |
go.sum, and go version |
maven |
~/.m2/repository |
pom.xml |
gradle |
~/.gradle/caches, ~/.gradle/wrapper |
the Gradle wrapper properties, lockfile and build scripts |
- Keys. A key is
<toolchain>/<lockfile digest>. The digest covers the git blob ids of the kind's lockfiles in the checkout's root. The toolchain is the version line of the tool that built the files, when the files depend on it (node_modules,.venv,target, the Go build cache), andanywhen they don't. - Fallback. As with
restore-keysin GitHub Actions, a restore takes the exact key first. If that misses, it takes the newest entry with the same toolchain: its private layer first, then the shared layer. A lockfile that changed still gets most of what it needs, and the package manager fetches the rest. - Entries are immutable. A second save under the same key is refused (409). A new lockfile makes a new key.
- In the background. After the clone, hangar runs
hangar-cache restorein the sandbox withnohupand moves on, without waiting for it. The agent's instructions say the caches are being restored, and thathangar-cache waitblocks until they are done (it returns at once when they are).hangar-cache statussays what each kind got:hit,partial(a fallback key),miss,skip, orerror. - Nothing is overwritten. Archives are extracted with
--skip-old-files, so whatever the sandbox already has wins. A directory in the checkout (node_modules,.venv,target) is restored only when git ignores it, so the agent's diff stays its own. It is not restored at all when the sandbox started from a repo build, whose image already has it. - Repo builds wait for their restore before
install(up toHANGAR_BUILD_CACHE_WAIT_MINUTES), since both write the same directories. They save afterinstall, and the build's log (hangar env builds) says what was restored and what was saved.
In the sandbox:
hangar-cache status # state: warming | warm | interrupted | off, and each kind's result
hangar-cache wait [SECONDS] # block until the restore is done
hangar-cache save [KIND...] # save the caches as they are now (to your private layer)
hangar-cache kinds # the kinds this repo has, and their keys
Compile caches¶
All three are served on the gateway's port under /cache, with the sandbox's grant as the credential.
sccache uses its WebDAV backend. The sandbox gets SCCACHE_WEBDAV_ENDPOINT=<gateway>/cache/sccache/ and
SCCACHE_WEBDAV_TOKEN=<grant> (bearer), plus SCCACHE_WEBDAV_RW_MODE=READ_ONLY when it may not write. sccache's
WebDAV backend is opendal's webdav service, which by default checks for parent directories with PROPFIND and
creates them with MKCOL before a PUT. hangar answers both: keys are flat, so every directory exists. Reads are
GET and HEAD, and DELETE, MOVE and COPY are refused. RUSTC_WRAPPER points at
~/.hangar/bin/hangar-rustc-wrapper, which runs rustc through sccache when the image has sccache and runs rustc
directly when it doesn't, so a sandbox without sccache builds as before. An environment that maps one of these
variables itself (in env: or secrets:) keeps its own.
Bazel uses its HTTP caching protocol: GET and PUT under /ac/ (action results) and /cas/ (outputs, named by
their SHA-256). hangar adds a block to ~/.bazelrc, Bazel's home rc file, which Bazel reads after the workspace's
.bazelrc:
# >>> hangar build caches (docs/caches.md)
build --remote_cache=http://hangar:<grant>@<gateway>/cache/bazel
build --remote_upload_local_results=false # only when the sandbox may not write
# <<< hangar build caches
The rest of the file is kept. A PUT to /cas/<sha256> whose body doesn't hash to its name is refused.
Turborepo uses the remote cache API (/v8/artifacts/...). The sandbox gets TURBO_API=<gateway>/cache/turbo,
TURBO_TOKEN=<grant> and TURBO_TEAM=hangar (the team is ignored, since the grant decides the repo), plus
TURBO_REMOTE_CACHE_READ_ONLY=1 and TURBO_CACHE=local:rw,remote:r when it may not write. hangar serves
GET/HEAD/PUT /v8/artifacts/{hash} (with x-artifact-duration and x-artifact-tag, so signed artifacts
verify), POST /v8/artifacts (the query), GET /v8/artifacts/status and POST /v8/artifacts/events.
Turborepo's artifact signatures (TURBO_REMOTE_CACHE_SIGNATURE_KEY) work through hangar unchanged: the tag is stored
and returned.
These were checked on 2026-10-15 against the tools' own documentation: sccache's docs/Webdav.md (0.17.0), opendal's
webdav service page, bazel.build's remote caching and bazelrc pages, and turborepo.dev's remote cache OpenAPI spec
and system environment variables page.
Storage¶
- Content-addressed and encrypted. Each object is split into 8 MiB chunks. A chunk is named by the SHA-256 of its plaintext and sealed with the vault key (Fernet, as checkpoints are) before it is stored, under its repo's own prefix. Equal chunks within a repo are stored once. Equal chunks in two repos are two objects.
- Checked on every read. Each chunk must decrypt with a vault key and hash to its name, and the whole object
must hash to its entry's digest. A chunk that fails is a miss: the entry is dropped, and a stream already under
way is cut short, which every client treats as a failed fetch.
hangar-cachethen checks the archive's SHA-256 against the digest hangar sent before it extracts anything. - The index is
caches.dbon the data volume, apart from hangar's database: compile caches write many small entries, which don't belong in the state database or its backups. If the index is lost, the caches are lost, and the nightly sweep deletes the objects nothing names any more. - Retention. When a write takes a repo over
HANGAR_BUILD_CACHE_REPO_MAX_GB, or all repos overHANGAR_BUILD_CACHE_MAX_GB, entries are evicted down to 90%: private layers' first, then the least recently used. The nightly pass (HANGAR_DOCTOR_SCHEDULE) drops entries past their retention and sweeps leftover objects. - Key rotation. A retired vault key makes the entries it sealed unreadable. They turn into misses and are dropped. Caches are rebuilt by the next build, so a rotation doesn't re-encrypt them.
Operating them¶
hangar status shows the caches' size against the caps, the biggest repos (shared and private bytes, last use), and
since the last start the hits, misses, writes, read-only refusals and integrity failures. The same is in GET
/api/status and the hangar://status resource. Admins also have:
hangar admin caches ls # the same, on its own
hangar admin caches purge acme/app # every entry of a repo (audited)
hangar admin caches purge acme/app --layer private
hangar admin caches purge --all
REST: GET /api/caches, DELETE /api/caches?repo=&layer= (all=true for every repo). Writes to dependency caches
are audited (cache.write). Compile cache writes are only counted, since there are thousands per build.
If you suspect a shared layer was poisoned, purge the repo. The next build writes it again.
Measured¶
tests/test_buildcache.py::test_bench_npm_install_cold_and_warm (run it with HANGAR_CACHE_BENCH=1) installs a
fixture project, tests/fixtures/cache-bench, on the fake provider through a real gateway. The project is express,
eslint, typescript and lodash: 162 packages. It first runs in a repo build's sandbox with no caches (npm ci, then
hangar saves ~/.npm and node_modules), then in a session's sandbox, which restores and has its dependencies
before running anything. Three runs on a 2-vCPU machine with a fast registry connection:
| cold (no caches) | warm | |
|---|---|---|
| Dependencies usable | npm ci: 3.9 s, 4.7 s, 5.0 s |
restore 0.9 s, 1.0 s, 1.3 s (node_modules back; nothing to install) |
npm ci anyway |
the same | 2.6 s, 3.1 s, 3.1 s (from ~/.npm, nothing fetched) |
| Saving (in the build) | 1.2 to 1.4 s, 17 MB compressed |
The gain grows with the registry's distance and the size of the tree. It is largest for compiled output (target,
the Go build cache, sccache, Bazel and turbo), where warm means not compiling at all. Those weren't measured here,
since this machine runs no Cargo builds.
Limits of this version¶
- Caches need the gateway: there are none for direct-mode sandboxes.
- The C and C++ compilers aren't wrapped with sccache (only rustc is), and Gradle's and ccache's HTTP caches aren't wired up, although the WebDAV endpoint speaks their protocol.
- Only lockfiles in the checkout's root are keyed. A monorepo's nested lockfiles share the root's key, and the fallback covers the rest.
- A restore runs once, after the clone. A sandbox whose lockfile changes mid-session restores nothing new. Its
package manager fetches the difference, and
hangar-cache savekeeps it for the session's next sandbox.