CI Nix binary cache (Cachix)¶
GitHub Actions workflows in opendefensecloud/solution-arsenal run
inside the project's nix dev shell (see issue #649). To keep cold-start
cost low, one binary cache sits in front of every nix store fetch:
- Cachix — org-wide binary cache at
https://opendefensecloud.cachix.org. Public read; org-secret-authed write. Shared across every repo underopendefensecloudthat wires incachix-action.
Workflows reference this via two pinned actions in order:
DeterminateSystems/nix-installer-action (with determinate: false to
use upstream Nix instead of the Determinate fork), then
cachix/cachix-action.
How to consume the cache (workflow author)¶
Drop the two-action stack into any workflow that needs nix tooling:
- name: Install nix
uses: DeterminateSystems/nix-installer-action@<sha> # pin per update-action-pins
with:
determinate: false # upstream Nix, not the Determinate fork
diagnostic-endpoint: '' # don't post install telemetry
- name: Use opendefensecloud Cachix cache
uses: cachix/cachix-action@<sha>
with:
name: opendefensecloud
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
signingKey: ${{ secrets.CACHIX_SIGNING_KEY }}
Both org-level secrets are required for push:
CACHIX_AUTH_TOKENauthenticates with Cachix's HTTP API.CACHIX_SIGNING_KEYsigns each store path before upload — see the Signing model (BYOK) section under Risks for why we hold the signing key ourselves rather than letting Cachix manage it.
Fork PRs receive neither secret (GitHub doesn't forward org secrets
to forks), and cachix-action auto-falls-back to read-only.
…then wrap subsequent run: steps in the dev shell either via job-level
defaults.run.shell: nix develop --command bash -e {0} or per-step
shell: override. See .github/workflows/helm-lint.yaml for the
simplest example.
How to consume the cache (local development)¶
The cache is world-readable. To benefit from pre-built derivations on a local machine without any account or login:
cachix use opendefensecloud
That writes https://opendefensecloud.cachix.org into your Nix
substituter list. Subsequent nix develop / nix build invocations
will hit Cachix before falling back to source builds.
No write access; you only push from CI.
One-time setup (org admin reference)¶
These steps are run once when the cache is provisioned. Documented here for future-readers and in case the cache needs to be recreated.
About signing keys. By default Cachix generates the signing
keypair server-side and holds the private half itself. That puts
Cachix in the trust boundary for anything signed as authentic (see
Signing model (BYOK) under Risks). To keep the private key on our
side, we use cachix generate-keypair — a CLI command that
generates the keypair locally, registers the public half with
Cachix, and prints the private half to stdout for us to store
securely.
Cachix has no web-UI surface for uploading a pre-existing public
key. The cachix generate-keypair CLI is the only supported path
(cachix/cachix#292 is
the open feature request for "add existing public key"; it's been
open since 2020 and has no timeline). If we ever needed to rotate
the key on the existing cache, we'd rerun generate-keypair and
Cachix would append a new trusted public key alongside the old one.
Steps¶
-
Sign in to https://cachix.org/ with a personal GitHub account that has org-admin rights on
opendefensecloud. First sign-in triggers Cachix's OAuth App authorisation flow; if the org restricts third-party OAuth Apps under Settings → Third-party Access, approve the Cachix app at that moment. -
Create a cache named
opendefensecloud(lowercase, matches the GitHub org slug). Public read access enabled. Do not generate a signing key at this step — we'll do that via the CLI in step 4 so the private half stays local. -
Get a personal auth token with admin permissions. This is important and easy to miss:
cachix generate-keypairneeds a personal auth token that has admin on the target cache. A per-cache write token is not enough; per-cache tokens can push store paths but cannot alter cache configuration (which is what registering a signing key does).
Get the personal token from https://app.cachix.org/personal-auth-tokens. Store it locally for the next step:
export CACHIX_AUTH_TOKEN=<personal-admin-token>
- Generate the signing keypair on the machine you're on:
cachix generate-keypair opendefensecloud
This does three things atomically:
- generates the keypair,
- registers the public half with Cachix as the trusted key
for the opendefensecloud cache,
- prints the private half to stdout. Capture it — Cachix does
not store the private key and there is no way to retrieve it
later. Treat it like any long-lived credential (never commit,
never paste into a chat / ticket).
-
Generate a write authentication token at the cache's Settings → Auth Tokens page. This is a different token from the personal one used in step 3 — it's cache-scoped, write-only, and safe to store in CI secrets.
-
Add two organisation-level GitHub Actions secrets: GitHub →
opendefensecloudorg settings → Secrets and variables → Actions → New organisation secret. Repository access Selected repositories with every solar-sibling repo that consumes the cache. CACHIX_AUTH_TOKEN— the write token from step 5 (NOT the personal admin token from step 3; that stays on the admin's machine and can be discarded once setup is done).-
CACHIX_SIGNING_KEY— the private key printed by step 4. -
Verify the setup with
cachix doctor <cache-name>:
cachix doctor opendefensecloud
The doctor command reports the cache's configured public keys
and whether they match what the client thinks it should trust.
Expected output includes opendefensecloud-1:... under the
cache's public keys section (with an actual key body, not an
empty list). An empty Cache public keys: [] in the Cachix
daemon logs from a CI run means step 4 didn't stick — most
likely CACHIX_AUTH_TOKEN in step 3 wasn't a personal admin
token, so the public-key registration failed silently.
- Securely discard the personal admin token from step 3. Everything else lives in the org secret store from now on.
cachix-action adds the substituter and trusted public key
automatically; nothing needs declaring in flake.nix.
Risks and what to monitor¶
Cachix charging if the cache outgrows the free tier¶
The single most important thing to watch. Cachix's free tier covers OSS public caches up to roughly 10 GB at the time of writing (verify current at https://cachix.org/pricing). Once the cache exceeds that threshold, Cachix bills the cache owner — and the cache will grow indefinitely under normal CI operation unless something stops it.
How the cache grows:
- Every successful CI run pushes new nix-store derivations: build outputs for each new flake input version, transitive nixpkgs updates, anything Renovate bumps.
- Renovate-driven flake updates are the biggest single source of growth. A nixpkgs bump invalidates a large fraction of the cached store at once.
- The cache does not automatically evict old derivations. Without intervention, every historical nixpkgs commit's outputs accumulate forever.
How to prevent surprise billing:
- Set a billing alert at the GitHub-org level — not at a personal level. The Cachix dashboard surfaces cache size; check it quarterly. If size approaches the free-tier ceiling, decide between paying, pruning, or migrating.
- Configure cache retention. Cachix exposes a Garbage Collection
policy under the cache's Settings → Garbage Collection page. The
conservative default keeps everything; the recommended posture for
this cache is delete unreachable paths older than N days (start
with 30 days). Reachability is computed from the current
flake.lockfiles of consuming repos, so anything currently in use stays; abandoned derivations expire. - Watch the dashboard, don't trust silent steady-state. A single large new dependency (e.g. a new browser binary or a new toolchain) can add multiple GB in one CI run. The dashboard's size graph makes this obvious; no email alert exists at free tier.
If the cache does outgrow the free tier:
- Short-term: enable a more aggressive garbage-collection window
(e.g. 7 days), or run a one-off
cachix gcto manually prune unreachable paths. - Long-term: consider paying for an entry-tier plan ($5/month range at the time of writing for moderate usage), or migrate to FlakeHub Cache (see Alternatives below) which has different free-tier mechanics.
Why this risk is non-trivial: a forgotten OSS cache that quietly grew past the free tier and started billing a credit-card-on-file (or worse, the personal card of whoever signed up) is a known failure mode for similar SaaS services. Mitigation is policy + monitoring, not configuration we can ship in this repo.
Signing model (BYOK)¶
Why we hold our own signing key. Cachix's default cache-creation flow generates the Nix store signing keypair on Cachix's servers; the private half stays with Cachix and the cache returns store paths signed under that key. That puts Cachix in the supply-chain trust boundary: anyone who can convince Cachix to sign arbitrary store paths on our behalf (server compromise, internal misuse, compelled disclosure, an auth-layer bug that cross-bleeds two customers' caches) can ship a derivation that our consumers — CI runs and local dev shells — verify as legitimate.
This cache is configured with bring-your-own signing key (BYOK).
We generate the keypair locally during setup, hold the private half
in the CACHIX_SIGNING_KEY org secret, and Cachix only sees the
public half. Cachix can still host bytes, throttle, or delete the
cache — but it cannot forge a signed derivation.
Trust boundary in plain terms:
- Without BYOK: trust Cachix (the company), Cachix's infrastructure, every Cachix employee with access to the signing keystore.
- With BYOK: trust whoever holds the
CACHIX_SIGNING_KEYorg secret — i.e. the GitHub org's secret store and the maintainers with admin access to it.
Key rotation playbook. Treat the signing key like any other
long-lived secret; rotate annually, or sooner on suspected
compromise. Cachix supports multiple public keys per cache
simultaneously — every invocation of cachix generate-keypair
appends a new key alongside the existing ones — which is what makes
rotation safe.
Prerequisite: a personal auth token with admin permissions on the cache (see step 3 of the one-time setup). Per-cache write tokens are not sufficient — key registration is a configuration change.
- Export the personal admin token:
export CACHIX_AUTH_TOKEN=<personal-admin-token>
- Generate a new keypair and register the new public key:
cachix generate-keypair opendefensecloud
Capture the printed private key. Cachix now trusts signatures under either the old or the new public key.
-
Update the
CACHIX_SIGNING_KEYorg secret with the new private key. The next CI run signs all new uploads under the new key. Existing cache content stays valid under the old public key. -
Wait at least one consumer-cache lifetime (a week is conservative) so any cached substituter state on devs' machines has refreshed to include the new public key.
-
Remove the old public key. Cachix's UI exposes key deletion under the cache's settings page (this operation is supported even though key addition is not — the asymmetry is a Cachix quirk). If deleting from the UI proves impossible, Cachix support can do it.
-
Verify the rotation with
cachix doctor opendefensecloud— the old key should be absent and only the new one listed. -
Securely discard the personal admin token from step 1.
Auth token rotation (orthogonal to signing — token controls write access, signing key controls what the cache says is authentic):
- Rotate
CACHIX_AUTH_TOKENannually. Cachix supports multiple write tokens per cache concurrently — issue a new token, update the org secret, then revoke the old token from Cachix. - Limit repository-access scope on both org secrets to repos that actually need to push. Read-only consumers (local dev, fork PRs) don't need either secret.
OAuth App approval is one-way¶
Cachix's OAuth App stays authorised on opendefensecloud until an
admin revokes it from the GitHub org settings page. Not a problem in
normal operation; mention here so it shows up in the audit trail if
someone wonders why Cachix has third-party-app access on the org.
Fork PR pushes are silently disabled¶
Fork PRs don't receive organisation secrets — CACHIX_AUTH_TOKEN
arrives empty in those runs. cachix-action auto-detects this and
falls back to read-only. Fork-PR builds still benefit from the cache
(reads are public) but don't push their build outputs. This is the
secure default; without it a malicious fork PR could poison the cache.
If a fork PR fails with a Cachix push error, the symptom is a warning-level log line — not a hard failure. The build still succeeds via cold compile.
Alternatives we considered¶
FlakeHub Cache (Determinate Systems)¶
Hosted Nix binary cache from the same vendor that ships
nix-installer-action. Different model from Cachix on three axes:
- Auth: GitHub App, org-installed. Runtime auth is GitHub Actions
OIDC — no long-lived
AUTH_TOKENto rotate. Fewer secrets to manage on the org side. - Setup: install the FlakeHub GitHub App on
opendefensecloudonce; consuming repos addDeterminateSystems/flakehub-cache-action(or similar) to their workflows. No personal-account intermediary. - Pricing: free for OSS; commercial tiers via Determinate Systems. At the time of writing, smaller install base than Cachix but actively developed.
Why we didn't pick it for this ticket:
- Cachix's OAuth flow is broadly understood; FlakeHub is the newer entrant.
- Switching from Cachix to FlakeHub later is mechanical — both expose the standard nix-store HTTP binary-cache protocol; consumers flip one substituter URL. Picking Cachix first is not a one-way door.
- We do not need OIDC's no-long-lived-secret property strongly enough
to take on the smaller-ecosystem risk; BYOK on Cachix already
removes Cachix from the signing-trust boundary, and our two
remaining long-lived secrets (
CACHIX_AUTH_TOKEN,CACHIX_SIGNING_KEY) rotate annually.
If the Cachix free-tier billing risk above ever becomes acute (e.g. the cache outgrows free tier and we don't want to pay), FlakeHub is the natural escape hatch.
Self-hosted Attic¶
Open-source Nix binary cache server (PostgreSQL + S3-compatible backend). Maximum control, no third-party dependency, no SaaS billing risk — but we'd be operating a server. At our size the ops load outweighs the savings. Reconsider if the project ever runs its own infrastructure independently.
Plain actions/cache against the nix store¶
Just persist ~/.nix/store via GitHub's built-in cache action. Cheap
(no external accounts), but the Nix store is a many-small-files tree
that actions/cache handles poorly — restore times balloon, and the
GHA 10 GB cap is shared with everything else
(see Caches in play).
DeterminateSystems/magic-nix-cache-action (tried and dropped)¶
Determinate Systems' polished wrapper around actions/cache that
persists the nix store between jobs in the same repo. We wired it
alongside Cachix during PR #661 as a same-job-restart fallback layer.
In practice the post-step spent ~487 s/run uploading every
nix-store path to GHA cache — regardless of whether Cachix already
held the same content. That created ~2500 tiny cache entries per
repo, filled the shared 10 GB actions/cache pot (which also holds
237's Docker cache-mount preservation), and started evicting our¶
real caches. use-flakehub: false didn't help because the GHA-cache
upload path is the offending workload.
Removed. Cachix is content-addressed and world-readable — same "warm nix store between runs" property at a different layer, without the local upload thrash.
Caches in play¶
Two independent caches are in play in the workflows this PR touches:
| Aspect | Cachix (opendefensecloud.cachix.org) |
GHA actions/cache for Go build+module caches |
|---|---|---|
| What it stores | Nix-store derivations only (.narinfo + .nar) |
~/go/pkg/mod and ~/.cache/go-build |
| Where | Cachix CDN | GitHub-managed object store, scoped per-repo |
| Budget | Free tier ~10 GB; growth risk — see Risks | 10 GB per repo, hard cap, LRU eviction |
| Scope | Global across all opendefensecloud repos | Per ref (branch / PR), trust-scoped per #237 pattern |
| Auth (read) | Public, zero config | Implicit |
| Auth (write) | CACHIX_AUTH_TOKEN + CACHIX_SIGNING_KEY (BYOK); fork PRs auto-fallback to read-only |
Implicit |
| Cross-repo sharing | Yes | No |
| Cross-PR fork sharing | Yes (read) | No |
| Failure mode if missing | Cold rebuild from source | Cold go mod download + full compile |
| Failure mode if poisoned | Content-addressed; cache cannot lie about derivation outputs (hash check fails) | Trust-scoping mitigates (see #237) |
They cover different things. Cachix serves nix-store derivations
(Go toolchain, all system tooling, everything the flake evaluates).
The GHA go-cache-* entries serve Go's own incremental compile
outputs, which live on top of whatever Go binary Cachix provides.
Both are needed; neither replaces the other.
The GHA-cache pot is shared with #237's Docker cache-mount preservation. If it starts getting tight, see #237's list of mitigations — they apply here too.
Glossary¶
actions/cache— GitHub's built-in cache action. Per-repo, per-ref, blob-store-backed. Used in this workflow for Go's module and build caches (go-cache-*scopes).- Cachix — third-party Nix binary cache provider. OAuth-based
setup. Org-owned caches at
https://<org>.cachix.org. - FlakeHub Cache — Determinate Systems' hosted Nix binary cache. GitHub-App-based setup, OIDC runtime auth.
- Binary cache substituter — a URL Nix queries before building any derivation; if the substituter returns the prebuilt output with a matching hash, Nix uses it instead of rebuilding.
.narinfo/.nar— Nix's storage formats..narinfois the metadata index;.naris the content-addressed archive of a built derivation.- Cache garbage collection — Cachix-side process that deletes
store paths no longer referenced by any registered
flake.lock. Configured per-cache under Settings → Garbage Collection. - BYOK (Bring-your-own key) — option at Cachix cache creation to supply the public signing key yourself, keeping the private half off Cachix's servers. Used by this cache; see Signing model (BYOK) under Risks.