Collectivenix fleet

30 machines · 4 platform classes · 1 registry

System

Delivery

A phone should not compile a desktop environment, and a laptop should not spend an hour fetching nixpkgs it already has. Collective builds machines on dedicated cache nodes from immutable revisions, serves the results from its own binary caches, and activates them in a way that never moves a machine backwards.

Switching a machine

cltv switch wraps the platform's rebuild command (nixos-rebuild, darwin-rebuild or nix-on-droid) and does the parts that make it reliable around a private, input-heavy flake.

  1. Pick healthy caches

    Every self-hosted cache is probed concurrently for nix-cache-info, with a 2-second connect and 4-second total limit. The substituter list is replaced by the ones that answered, so a dead peer never costs a minute of retries per path. Failed peers stay out even when --retry reruns the rebuild.

  2. Fetch private inputs as you

    The rebuild evaluates as root, with no SSH agent. So cltv first asks Nix which input source trees are missing (flake archive --dry-run --json) and fetches only those as the user. The lock has about 140 source trees, including 18 nixpkgs revisions.

  3. Pin what was fetched

    Under lazy trees nothing else roots those inputs, so the next garbage collection would delete them and the next switch would copy gigabytes again. They are pinned with indirect GC roots in ~/.config/collective/gcroots.

  4. Rebuild, logged

    With --option accept-flake-config true, so caches and keys the new configuration declares are usable during the switch that installs them, and with line-oriented logs that survive SSH.

  5. Drain around activation

    Activation restarts the agent daemon, its terminal multiplexer and the self-hosted CI runners. Where the agent orchestrator, Cacophony, offers caco node drain, the new generation is built first, then the node is drained: new work is held, each runner listener stops once its job finishes, and agents are checkpointed and parked. Only then is it activated, and caco node undrain resumes exactly what was parked, even if activation failed. A node that never becomes quiet is released again and not activated (exit 75). Cached activation drains the same way.

OptionDoes
--cachedActivate the generation published by this machine's cache node instead of evaluating
--remote HOST, --remote-coresBuild on another machine over SSH
--max-jobs N0 (default) builds only on remote builders; otherwise this many local jobs
--retry SECONDSRetry a failed switch after a pause
--updateUpdate marked flake inputs first; off by default, so the lock is used as is
--seedPush the new closure to the Attic caches afterwards
--no-archive, --repin-inputsSkip the input archive, or force its GC roots to be rebuilt
--rawRun the plain __COLLECTIVE_NIX_SWITCH_CMD
--drainDrain the node through Cacophony around activation; off by default
--drain-args FLAGSExtra flags for caco node drain, such as --interrupt-runners
-qNo build logs or traces

The full list, including the fleet options below, is in the CLI reference. cltv nix revert rebuilds from the store copy of the flake, which undoes a specialisation switch without picking up new changes.

Prebuilds on cache nodes

Six WSL devboxes run Attic binary caches. Each registry machine may name one of them in prebuild; that node's collective-cache-prebuild service is enabled automatically and builds its assigned machines on a two-hour timer. At this revision twelve machines are prebuilt, including every phone and the Mac's Linux VM.

A scheduled prebuild on a cache nodeA line of six stations runs every two hours on a cache node. One: the timer fires. Two: resolve the newest revision of main, accepting only a forty-character SHA from a successful API call. Three: archive that immutable revision with its private inputs, using only peer caches that answer. Four: build every machine assigned to this node, stopping at the first failure, each output held by a temporary garbage-collector root. Five: push the closures to this node's Attic cache, one path at a time. Six: publish metadata for each machine, its store path, revision and build time. A branch below the build station handles failure: if main has moved on since the attempt began, retry at once with the new revision; otherwise wait for the next timer. timer every 2 h resolve main 40-hex SHA only archive healthy peers build fail fast push serial uploads publish <machine>.json GC root held until published failed has main advanced? yes: retry with the new revision no: wait for the next timer; the last good publication stays
A prebuild run.One immutable revision per run; one failed machine stops the batch, since its siblings share the same inputs and would likely fail the same way.

Each run writes a status record, and each successful machine a publication. A running build or an old failure is never mistaken for a new publication.

{ "schema": 1, "node": "lantern", "state": "success", "message": "prebuild completed",  "startedAt": 1760651220, "finishedAt": 1760655983, "exitCode": 0 }

The service materialises only its own node's push token from SOPS, sets GIT_LFS_SKIP_SMUDGE=1 so identical revisions hash identically on every builder, and opts out of restart-on-change so an ordinary switch of the builder does not discard hours of work. Phones and the aarch64 VM are built under emulation on x86 nodes with two jobs of two cores and a 24-hour budget.

Why it looks like this

Most of these properties were added after a specific failure:

FailureNow
A rate-limited GitHub API call printed an error body that was accepted as a revision, then treated as "main advanced", retrying in a loopOnly a successful call returning exactly 40 hex characters is a revision
An unreachable peer cache cost minutes of retries in the archive stage, where build-stage filtering did not applyArchive and build share one health-filtered substituter list
A closure built with --no-link was garbage-collected while its upload was being retriedA temporary GC root holds each output until publication finishes
Concurrent uploads overwhelmed a cache's database pool (up to ten chunk tasks per path, eight paths at once)Prebuild uploads start serial; manual pushes halve concurrency on each failure: 8, 4, 2, 1
A switch of the builder restarted the prebuild unit mid-buildThe unit is not restarted when its definition changes
A status query hung on one node and stalled the reportPer-request deadlines; failures report unknown, never a partial snapshot

Self-hosted binary caches

The caches are Attic servers on six devboxes, reached over the tailnet. Machines prefer them to the public caches, and their signing keys are part of every machine's Nix configuration.

CachePriorityRole
cache.nixos.org40Upstream
Attic peers41Prebuilt machine closures and everything they share
Public extras (CUDA, GHC, nix-community, Nix-on-Droid)n/aDeclared in the flake's nixConfig
FlakeHub Cache45Asked last; only fills misses

Peer caches are declared per machine, never in the flake's nixConfig: accepted flake configuration would otherwise bring offline peers back after the switch-time health filter removed them.

CommandDoes
cltv nix cache statusHealth of every self-hosted cache
cltv nix statusScheduled prebuild state and published generations, per node
cltv nix cache seed [-c CACHE] [-p PATH]Push this system's closure, or a path, to one cache or all of them
cltv nix cache tag, cache pushPush a version tag (after committing, for push) to start the "Build & Cache Collective" workflow

Activation that only moves forward

On a machine with a prebuild assignment, cltv switch --cached needs no evaluation at all, which matters on a phone. It runs scripts/activate-cached, whose cache map is a literal list so it works before any flake is evaluated.

cltv switch --cachedA line runs from the device on the left to activation on the right. First the device reads its publication from its assigned cache node over SSH: a store path, a Git revision and a build time. If that path is already running, it stops. Otherwise it finds the revision it is running from its activation record or the system's configuration revision, and asks Git whether that revision is an ancestor of the published one. If not, or if either revision is unknown, it refuses, shown as a red branch. If so, it downloads the closure from the cache, trusting that cache's key, switches the system profile, runs the activation, and records the path and revision it activated. read publication ssh cache node compare already running? ancestry installed ≤ cached realise from its cache activate switch profile record state refuse older, divergent or unknown /var/lib/collective-prebuild/ <machine>.json { "path": "/nix/store/…", "revision": "…40 hex…", "builtAt": 1760… } nix-env --set switch-to-configuration
Cached activation.The local checkout serves only as a Git object database for the ancestry check; its own position does not matter.

The rule is ancestry, not timestamps. The installed revision comes from the record of the last cached activation, or from the system's configuration revision. The published revision must descend from it, so a stale publication, a rebuilt older branch or a divergent experiment is refused. Generations from before revisions were recorded are migrated once: a publication newer than the installed profile is accepted, and ancestry applies from then on.

cltv switch --cachedactivate-cached: quarry via lantern  revision: 40-hex revision  built:    build time  path:     /nix/store/…-nixos-system-quarry-version

Switching many machines

The everyday route is the justfile. Each machine gets its own tmux window over SSH, pulls the checkout and switches, using --cached only where a prebuild assignment exists:

just collective-switch all --cachedjust collective-switch quarry orchard

There is also an optional control plane, cltv-daemon: a small Rust service with GET /status (identity, current generation, last switch) and POST /switch, which runs cltv switch and streams its output. cltv switch --all or --machines drives it from a ratatui dashboard. It is off by default; its access control is the tailnet ACL, so it should only be enabled on machines reachable by trusted peers.

FlakeHub, alongside

FlakeHub is being evaluated next to Attic, not instead of it. A workflow on every push to main builds the same twelve closures on the same self-hosted builders as the prebuild service and pushes them to FlakeHub Cache, which only accepts uploads from CI. Machines read it last, and an expired token produces a warning, never a failed build. Publishing the flake itself waits on its size: the source archive is larger than FlakeHub's limit, almost all of it wallpapers.