Nix binary cache and build matrix for GitHub Actions: eval once, build checks in parallel jobs, deduplicated uploads, automatic GC, zero setup
Rust
112
408 commits
updated Sep 22, 2026
Hestia is a Nix binary cache for GitHub Actions. It stores build results in the GitHub Actions cache, so later runs download them instead of rebuilding. There is nothing to set up: no accounts, no secrets, no server to run. Add the action to your workflow and you have a binary cache.
How it differs from magic-nix-cache:
429 Too Many Requests.matrix subaction spreads
your flake's checks over parallel runners: evaluate once, build each
check as its own job.# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
actions: read # optional: detect evicted cache entries upfront
steps:
- uses: actions/checkout@v6
- uses: NixOS/nix-installer-action@main
- uses: Mic92/hestia@v3
- run: nix build .#
Everything built in your workflow gets cached; later runs (and PRs) pull from the cache instead of rebuilding.
Build jobs need no extra permissions for the cache itself: uploads
authenticate with the runner-injected ACTIONS_RUNTIME_TOKEN, which the
permissions: block does not scope. The optional actions: read lets
the daemon check upfront which cached packs GitHub has evicted, so
affected paths are rebuilt without a failed download attempt first.
You will also want a daily GC workflow on the default branch to stay within
the cache quota; copy .github/workflows/gc.yml
for that (its REST cache deletes are what need actions: write).
See Configuration for all action inputs.
Mic92/hestia/matrix turns a flake's checks into a GitHub Actions build
matrix: one runner per check, evaluated only once. The eval job runs
nix-eval-jobs, uploads each check's .drv closure to the cache, and
outputs the matrix of checks that are not cached yet. Each build job then
fetches its derivation from the cache and builds it by store path — no
per-job evaluation, no flake changes, and no job at all for checks whose
results are already cached.
jobs:
eval:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
any-jobs: ${{ steps.matrix.outputs.any-jobs }}
manifest-version: ${{ steps.matrix.outputs.manifest-version }}
steps:
- uses: actions/checkout@v6
- uses: NixOS/nix-installer-action@main
- uses: Mic92/hestia@v3
- id: matrix
uses: Mic92/hestia/matrix@v3
with:
flake: ".#checks" # default
nix-eval-jobs: "nix run nixpkgs#nix-eval-jobs --"
# runner-map: |
# x86_64-linux=ubuntu-24.04
# aarch64-darwin=macos-14,self-hosted
build:
needs: eval
if: needs.eval.outputs.any-jobs == 'true'
name: ${{ matrix.name }} (${{ matrix.system }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.eval.outputs.matrix) }}
steps:
- uses: NixOS/nix-installer-action@main
- uses: Mic92/hestia@v3
with:
wait-manifest-version: ${{ needs.eval.outputs.manifest-version }}
- name: Prefetch drv closure
run: "$HESTIA_BIN" prefetch ${{ matrix.installables }}
- run: nix build -L ${{ matrix.installables }}
Jobs can steer the matrix from their meta attributes (read via
nix-eval-jobs --meta, no flake plumbing needed):
checks.x86_64-linux.mycheck = pkgs.hello.overrideAttrs (old: {
meta = (old.meta or { }) // {
hestia.group = "small-checks"; # share one runner with the group
hestia.os = [ "self-hosted" "big" ]; # override the runner labels
};
});
The prefetch step prepares any references omitted by the upstream filter,
then pulls the Hestia-backed part of the drv closure with one request
(GET /closure/<hash>[,<hash>] streams it in nix-store --export format,
references first; unknown roots return 404). This keeps the Hestia-backed paths
from being substituted one narinfo+NAR round trip at a time.
Importing unsigned paths requires the calling user to be a Nix trusted user,
which the default GitHub runner setup already is.
Notes:
upstream-cache-filter so /closure exports remain importable into a
fresh store. Their inputs, including upstream-signed sources, therefore
count against the cache quota.upstream-cache-filter in both jobs and
filter-drv-closures in the eval job, then use hestia prefetch in build
jobs. Direct /closure imports cannot prepare omitted references, and
existing entries remain until no cached drv references them.attr, so nix build .#${{ matrix.attr }}
works as a per-job-eval fallback.Template repository with this workflow: Mic92/hestia-drv-test.
| hestia | magic-nix-cache | cachix | attic | |
|---|---|---|---|---|
| Status | stable | maintained | commercial service | self-hosted |
| Storage | GHA cache (free, 10 GB/repo) | GHA cache (free, 10 GB/repo) | cachix.org | your S3/disk |
| Accounts / secrets needed | none | none | auth token | server + token |
| Infrastructure to run | none | none | none | server, database, storage |
| Uploads only what changed (dedup) | yes | no (whole store paths) | no | yes |
| Rate-limit errors on big builds | no | yes (429) | no | no |
| Garbage collection | automatic (scheduled workflow) | none (LRU eviction only) | retention rules | policies |
| Cache shared beyond CI | no (CI-only by design) | no | yes (any machine) | yes |
| Signing | not needed (?trusted=true, localhost) | not needed | yes | yes |
| Telemetry | none | reports usage to Determinate Systems (opt-out) | — | none |
If developer machines should hit the cache too, you want cachix or attic instead; hestia only works inside CI.
A small daemon (hestia serve) runs alongside your CI job:
nix build ──built paths──▶ hestia ──upload──▶ GitHub Actions cache
nix build ◀─cached paths── hestia ◀─download─ GitHub Actions cache
To Nix, the daemon looks like a regular binary cache: Nix asks it for paths before building them and reports every path it does build. At the end of the job, new build results and their runtime dependencies (the full closure, nixpkgs packages included) are split into content-defined chunks, packed into a few large blobs, and uploaded. Embedded dependency hashes are normalized out before chunking so a chunk stays identical when only a reference's hash changed, and restored losslessly on the way back out. Chunks that are already in the cache are never uploaded again, and every download is hash-verified before Nix gets to see it. The worst thing corrupt or evicted cache data can cause is a rebuild, never wrong build inputs.
Every job records the paths it pushed and the paths it downloaded under a
root named <branch>-<system>, e.g. main-x86_64-linux. The branch part
comes from $GITHUB_REF_NAME (override with --branch), the system part is
detected (override with --system). Anything reachable from a root survives
garbage collection; everything else is deleted once it falls out of the push
grace period.
Matrix jobs of one workflow run share their root: their closures are unioned, however far apart the jobs finish. A new run replaces the root, so old closures become collectable.
Pull requests get their own roots (123/merge-x86_64-linux), so a PR cannot
evict paths the default branch still needs. Roots that stop being updated
(merged PRs, deleted branches) expire after --root-ttl (14 days by
default) and their paths become collectable.
Roots are how hestia decides what is still alive. They are unrelated to GitHub's own cache access scoping (who may read or write entries, see Security), which applies on top.
All inputs are optional; the defaults work for the quick start above.
| Input | Default | Description |
|---|---|---|
binary | — | Path to a pre-built hestia binary. Takes precedence over version. |
version | latest release | Release tag to download (e.g. v1.0.0). The download is verified against GitHub's build attestations. |
github-token | ${{ github.token }} | Token for the attestation API lookup. |
listen | 127.0.0.1:37515 | Substituter listen address. |
socket | /tmp/hestia/hook.sock | Post-build-hook unix socket path. |
drain-timeout | 300 | Seconds the post-job step waits for the final upload. |
upstream-cache-filter | false | Skip paths signed by an upstream cache instead of caching them (saves quota for big closures). |
upstream-cache-key-names | cache.nixos.org-1 | Space-separated key names treated as upstream caches by the filter. |
filter-drv-closures | false | Apply the upstream filter to registered derivation closures; requires upstream-cache-filter. Use hestia prefetch for bulk closure fetching. |
read-only | false | Substitute from the cache but never write to it (no post-build-hook, no drain). |
no-closure | false | Cache built paths only, without their runtime closure. |
The GC workflow takes one input: dry-run (plan only, delete nothing); see
.github/workflows/gc.yml.
The matrix subaction has its own inputs (flake, nix-eval-jobs,
runner-map, attr-prefix, skip-unmapped-systems); see
matrix/action.yml.
Running the hestia binary yourself instead of using the action? See the
CLI reference. How it all works under the hood:
architecture.
With nix-eval-jobs you can evaluate once and fan the results out into a
matrix of build jobs that build the .drv paths directly, skipping a
second evaluation. Derivations written during evaluation never trigger the
post-build-hook, so the eval job has to register them explicitly with
hestia hook (the action exports HESTIA_BIN and HESTIA_SOCKET for
this):
jobs:
eval:
runs-on: ubuntu-latest
outputs:
jobs: ${{ steps.eval.outputs.jobs }}
steps:
- uses: actions/checkout@v6
- uses: NixOS/nix-installer-action@main
- uses: Mic92/hestia@v1
- id: eval
run: |
nix run nixpkgs#nix-eval-jobs -- --flake .#hydraJobs > jobs.json
jq -r '.drvPath' jobs.json | xargs "$HESTIA_BIN" hook --socket "$HESTIA_SOCKET"
echo "jobs=$(jq -sc . jobs.json)" >> "$GITHUB_OUTPUT"
build:
needs: eval
runs-on: ubuntu-latest
strategy:
matrix:
job: ${{ fromJSON(needs.eval.outputs.jobs) }}
steps:
- uses: NixOS/nix-installer-action@main
- uses: Mic92/hestia@v1
- run: nix build "${{ matrix.job.drvPath }}^*"
The drain at the end of the eval job uploads the drvs together with their input closure (input drvs and sources), so the build jobs substitute them from the cache.
?trusted=true is safe hereHestia serves unsigned narinfos, and the action configures the substituter
URL with ?trusted=true so Nix accepts them. This does not weaken Nix's
trust model in CI: the substituter listens on 127.0.0.1 inside the job, and
everything it serves came either from the job's own builds or from cache
entries that only this repository's workflows could have written. If you
trust the runner to execute your build, there is nothing extra to trust
here.
GitHub gives each cache entry an access scope: a PR job can read the default branch's cache but can only write to its own PR scope, which is discarded when the branch is deleted. In practice this means:
main or by other PRs.
Its writes land in its own scope and disappear with it.main cached (which is just
already-public build outputs) and can fill its own scope with garbage,
bounded by the 10 GB repository quota that GitHub evicts by LRU anyway.pull_request_target / fork PRs never get write tokens for the base
scope; the standard GitHub Actions security guidance applies unchanged.Pack blobs are content-addressed (BLAKE3-named, hash-verified on every read), and NARs are verified against the manifest's SHA-256 NAR hash before being served. Anything that doesn't check out is treated as a cache miss and gets rebuilt.
actions/cache users). GitHub evicts
least-recently-used entries under pressure and after 7 days idle. Hestia
treats the cache as lossy: evicted paths are rebuilt and re-pushed.fallback = true (set by the action) this never fails a job.$ nix develop -c cargo test # unit + integration tests (fake GHA backend)
$ nix develop -c cargo clippy --all-targets -- -D warnings
$ nix fmt # treefmt (rustfmt, nixfmt, taplo, ...)
$ nix flake check # everything CI runs: fmt, clippy, tests, build
$ nix build .# # the hestia binary (static musl on Linux)
MIT
Rust
95.4%
JavaScript
2.6%
Shell
1.1%
Nix binary cache and build matrix for GitHub Actions: eval once, build checks in parallel jobs, deduplicated uploads, automatic GC, zero setup
Rust
112
408 commits
updated Sep 22, 2026
Hestia is a Nix binary cache for GitHub Actions. It stores build results in the GitHub Actions cache, so later runs download them instead of rebuilding. There is nothing to set up: no accounts, no secrets, no server to run. Add the action to your workflow and you have a binary cache.
How it differs from magic-nix-cache:
429 Too Many Requests.matrix subaction spreads
your flake's checks over parallel runners: evaluate once, build each
check as its own job.# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
actions: read # optional: detect evicted cache entries upfront
steps:
- uses: actions/checkout@v6
- uses: NixOS/nix-installer-action@main
- uses: Mic92/hestia@v3
- run: nix build .#
Everything built in your workflow gets cached; later runs (and PRs) pull from the cache instead of rebuilding.
Build jobs need no extra permissions for the cache itself: uploads
authenticate with the runner-injected ACTIONS_RUNTIME_TOKEN, which the
permissions: block does not scope. The optional actions: read lets
the daemon check upfront which cached packs GitHub has evicted, so
affected paths are rebuilt without a failed download attempt first.
You will also want a daily GC workflow on the default branch to stay within
the cache quota; copy .github/workflows/gc.yml
for that (its REST cache deletes are what need actions: write).
See Configuration for all action inputs.
Mic92/hestia/matrix turns a flake's checks into a GitHub Actions build
matrix: one runner per check, evaluated only once. The eval job runs
nix-eval-jobs, uploads each check's .drv closure to the cache, and
outputs the matrix of checks that are not cached yet. Each build job then
fetches its derivation from the cache and builds it by store path — no
per-job evaluation, no flake changes, and no job at all for checks whose
results are already cached.
jobs:
eval:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
any-jobs: ${{ steps.matrix.outputs.any-jobs }}
manifest-version: ${{ steps.matrix.outputs.manifest-version }}
steps:
- uses: actions/checkout@v6
- uses: NixOS/nix-installer-action@main
- uses: Mic92/hestia@v3
- id: matrix
uses: Mic92/hestia/matrix@v3
with:
flake: ".#checks" # default
nix-eval-jobs: "nix run nixpkgs#nix-eval-jobs --"
# runner-map: |
# x86_64-linux=ubuntu-24.04
# aarch64-darwin=macos-14,self-hosted
build:
needs: eval
if: needs.eval.outputs.any-jobs == 'true'
name: ${{ matrix.name }} (${{ matrix.system }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.eval.outputs.matrix) }}
steps:
- uses: NixOS/nix-installer-action@main
- uses: Mic92/hestia@v3
with:
wait-manifest-version: ${{ needs.eval.outputs.manifest-version }}
- name: Prefetch drv closure
run: "$HESTIA_BIN" prefetch ${{ matrix.installables }}
- run: nix build -L ${{ matrix.installables }}
Jobs can steer the matrix from their meta attributes (read via
nix-eval-jobs --meta, no flake plumbing needed):
checks.x86_64-linux.mycheck = pkgs.hello.overrideAttrs (old: {
meta = (old.meta or { }) // {
hestia.group = "small-checks"; # share one runner with the group
hestia.os = [ "self-hosted" "big" ]; # override the runner labels
};
});
The prefetch step prepares any references omitted by the upstream filter,
then pulls the Hestia-backed part of the drv closure with one request
(GET /closure/<hash>[,<hash>] streams it in nix-store --export format,
references first; unknown roots return 404). This keeps the Hestia-backed paths
from being substituted one narinfo+NAR round trip at a time.
Importing unsigned paths requires the calling user to be a Nix trusted user,
which the default GitHub runner setup already is.
Notes:
upstream-cache-filter so /closure exports remain importable into a
fresh store. Their inputs, including upstream-signed sources, therefore
count against the cache quota.upstream-cache-filter in both jobs and
filter-drv-closures in the eval job, then use hestia prefetch in build
jobs. Direct /closure imports cannot prepare omitted references, and
existing entries remain until no cached drv references them.attr, so nix build .#${{ matrix.attr }}
works as a per-job-eval fallback.Template repository with this workflow: Mic92/hestia-drv-test.
| hestia | magic-nix-cache | cachix | attic | |
|---|---|---|---|---|
| Status | stable | maintained | commercial service | self-hosted |
| Storage | GHA cache (free, 10 GB/repo) | GHA cache (free, 10 GB/repo) | cachix.org | your S3/disk |
| Accounts / secrets needed | none | none | auth token | server + token |
| Infrastructure to run | none | none | none | server, database, storage |
| Uploads only what changed (dedup) | yes | no (whole store paths) | no | yes |
| Rate-limit errors on big builds | no | yes (429) | no | no |
| Garbage collection | automatic (scheduled workflow) | none (LRU eviction only) | retention rules | policies |
| Cache shared beyond CI | no (CI-only by design) | no | yes (any machine) | yes |
| Signing | not needed (?trusted=true, localhost) | not needed | yes | yes |
| Telemetry | none | reports usage to Determinate Systems (opt-out) | — | none |
If developer machines should hit the cache too, you want cachix or attic instead; hestia only works inside CI.
A small daemon (hestia serve) runs alongside your CI job:
nix build ──built paths──▶ hestia ──upload──▶ GitHub Actions cache
nix build ◀─cached paths── hestia ◀─download─ GitHub Actions cache
To Nix, the daemon looks like a regular binary cache: Nix asks it for paths before building them and reports every path it does build. At the end of the job, new build results and their runtime dependencies (the full closure, nixpkgs packages included) are split into content-defined chunks, packed into a few large blobs, and uploaded. Embedded dependency hashes are normalized out before chunking so a chunk stays identical when only a reference's hash changed, and restored losslessly on the way back out. Chunks that are already in the cache are never uploaded again, and every download is hash-verified before Nix gets to see it. The worst thing corrupt or evicted cache data can cause is a rebuild, never wrong build inputs.
Every job records the paths it pushed and the paths it downloaded under a
root named <branch>-<system>, e.g. main-x86_64-linux. The branch part
comes from $GITHUB_REF_NAME (override with --branch), the system part is
detected (override with --system). Anything reachable from a root survives
garbage collection; everything else is deleted once it falls out of the push
grace period.
Matrix jobs of one workflow run share their root: their closures are unioned, however far apart the jobs finish. A new run replaces the root, so old closures become collectable.
Pull requests get their own roots (123/merge-x86_64-linux), so a PR cannot
evict paths the default branch still needs. Roots that stop being updated
(merged PRs, deleted branches) expire after --root-ttl (14 days by
default) and their paths become collectable.
Roots are how hestia decides what is still alive. They are unrelated to GitHub's own cache access scoping (who may read or write entries, see Security), which applies on top.
All inputs are optional; the defaults work for the quick start above.
| Input | Default | Description |
|---|---|---|
binary | — | Path to a pre-built hestia binary. Takes precedence over version. |
version | latest release | Release tag to download (e.g. v1.0.0). The download is verified against GitHub's build attestations. |
github-token | ${{ github.token }} | Token for the attestation API lookup. |
listen | 127.0.0.1:37515 | Substituter listen address. |
socket | /tmp/hestia/hook.sock | Post-build-hook unix socket path. |
drain-timeout | 300 | Seconds the post-job step waits for the final upload. |
upstream-cache-filter | false | Skip paths signed by an upstream cache instead of caching them (saves quota for big closures). |
upstream-cache-key-names | cache.nixos.org-1 | Space-separated key names treated as upstream caches by the filter. |
filter-drv-closures | false | Apply the upstream filter to registered derivation closures; requires upstream-cache-filter. Use hestia prefetch for bulk closure fetching. |
read-only | false | Substitute from the cache but never write to it (no post-build-hook, no drain). |
no-closure | false | Cache built paths only, without their runtime closure. |
The GC workflow takes one input: dry-run (plan only, delete nothing); see
.github/workflows/gc.yml.
The matrix subaction has its own inputs (flake, nix-eval-jobs,
runner-map, attr-prefix, skip-unmapped-systems); see
matrix/action.yml.
Running the hestia binary yourself instead of using the action? See the
CLI reference. How it all works under the hood:
architecture.
With nix-eval-jobs you can evaluate once and fan the results out into a
matrix of build jobs that build the .drv paths directly, skipping a
second evaluation. Derivations written during evaluation never trigger the
post-build-hook, so the eval job has to register them explicitly with
hestia hook (the action exports HESTIA_BIN and HESTIA_SOCKET for
this):
jobs:
eval:
runs-on: ubuntu-latest
outputs:
jobs: ${{ steps.eval.outputs.jobs }}
steps:
- uses: actions/checkout@v6
- uses: NixOS/nix-installer-action@main
- uses: Mic92/hestia@v1
- id: eval
run: |
nix run nixpkgs#nix-eval-jobs -- --flake .#hydraJobs > jobs.json
jq -r '.drvPath' jobs.json | xargs "$HESTIA_BIN" hook --socket "$HESTIA_SOCKET"
echo "jobs=$(jq -sc . jobs.json)" >> "$GITHUB_OUTPUT"
build:
needs: eval
runs-on: ubuntu-latest
strategy:
matrix:
job: ${{ fromJSON(needs.eval.outputs.jobs) }}
steps:
- uses: NixOS/nix-installer-action@main
- uses: Mic92/hestia@v1
- run: nix build "${{ matrix.job.drvPath }}^*"
The drain at the end of the eval job uploads the drvs together with their input closure (input drvs and sources), so the build jobs substitute them from the cache.
?trusted=true is safe hereHestia serves unsigned narinfos, and the action configures the substituter
URL with ?trusted=true so Nix accepts them. This does not weaken Nix's
trust model in CI: the substituter listens on 127.0.0.1 inside the job, and
everything it serves came either from the job's own builds or from cache
entries that only this repository's workflows could have written. If you
trust the runner to execute your build, there is nothing extra to trust
here.
GitHub gives each cache entry an access scope: a PR job can read the default branch's cache but can only write to its own PR scope, which is discarded when the branch is deleted. In practice this means:
main or by other PRs.
Its writes land in its own scope and disappear with it.main cached (which is just
already-public build outputs) and can fill its own scope with garbage,
bounded by the 10 GB repository quota that GitHub evicts by LRU anyway.pull_request_target / fork PRs never get write tokens for the base
scope; the standard GitHub Actions security guidance applies unchanged.Pack blobs are content-addressed (BLAKE3-named, hash-verified on every read), and NARs are verified against the manifest's SHA-256 NAR hash before being served. Anything that doesn't check out is treated as a cache miss and gets rebuilt.
actions/cache users). GitHub evicts
least-recently-used entries under pressure and after 7 days idle. Hestia
treats the cache as lossy: evicted paths are rebuilt and re-pushed.fallback = true (set by the action) this never fails a job.$ nix develop -c cargo test # unit + integration tests (fake GHA backend)
$ nix develop -c cargo clippy --all-targets -- -D warnings
$ nix fmt # treefmt (rustfmt, nixfmt, taplo, ...)
$ nix flake check # everything CI runs: fmt, clippy, tests, build
$ nix build .# # the hestia binary (static musl on Linux)
MIT
Rust
95.4%
JavaScript
2.6%
Shell
1.1%