NixOS
Layer 1: bare metal. Every host in the fleet is declared as NixOS in one flake at the repo root (
flake.nix), configured undernix/. A few hosts run something else and are being brought across β Fleet lists the machine inventory and the known divergence. Runbooks/Deploy a NixOS Host has the build/deploy/rollback commands; this page is architecture only.The registry:
nix/hosts/default.nixOne entry per NixOS closure this repo builds, and the only such list.
flake.nixderivesnixosConfigurations,packages,checks, and the deploy-host list for thenix runapps from it, so none of those can drift from each other.Each entry sets only what differs from the defaults:
system,tags,kind(hostorimage),baseline,module(defaults tonix/hosts/<name>.nix),artifact(theconfig.system.build.*attribute to publish as a package), andpackageSystem.Every host has exactly one file under
nix/hosts/. Configuration lives there β disko device, sops secrets, cluster role β not in the flake.
How a host is built:
mkHostflake.nixnever callsnixosSystemdirectly. Every closure goes throughmkHost(nix/lib/mkHost.nix), which takes one calling convention:{ system, tags, baseline, modules }. Everything a caller can pass is honoured; there is no second mode.mkHostprepends the baseline and setsnetworking.hostNameto the registry name withlib.mkDefault, so an image that takes its identity from elsewhere (nix/images/gce.nixreads it from GCE metadata) can still override it.specialArgspassed to every closure areinputs,name,tags, andnixos-raspberrypi(the board-support flake input, needed as a top-level specialArg by its own modules).Two baselines:
nix/profiles/fleet.nixβ every deployed host. Pulls inmise-dotfiles.nix,ddnsd.nix,nixos.nix,ssh.nix,tailscale.nix,user.nix, plus locale, firewall, sudo, cron, and the node exporter.nix/profiles/base.nixβ the floor under both: thestateVersionand thehomelab.fleet.*option surface. Images that deliberately want less (nix/images/wsl.nix,nix/images/container.nix,nix/hosts/rackpi5.nix) take this and compose their own narrow import list.
A host diverges from the baseline by setting a
homelab.fleet.*option, not by omitting an import β an absent capability is visible in the host file rather than inferred from what it forgot to pull in.nix/profiles/pi-zero.nixis the worked example:miseDotfiles,metrics, andterminfoall off, each with its reason.nix/profiles/holds the shared stacks:k8s-node.nix(x86 hardware + disko + the k8s service; reads its cluster from thefolly/offsitetag),pi-zero.nix,pi4-kiosk.nix, andpi5-nvme.nix.flake.nixholds inputs and output wiring only β no logic.nix/lib/registry.nixturns the registry intonixosConfigurations,deployHosts, andpackagesFor;nix/lib/checks.nixholds the fleet assertions;nix/lib/mkHost.nixbuilds a single system. Adding a host means one file innix/hosts/and one registry entry, and nothing inflake.nixchanges.Cross-host wiring lives in
nix/lib/registry.nixundercrossHostModules: edges that belong to neither host alone because they need a derivation from the other.spore'sservices.spore.nativeBootTargets.rackpi5is the only one β it needsrackpi5'spiBootImg, which exists only oncerackpi5's own configuration has been evaluated.
Disk layout: disko
nix/disko/default.nixdeclares a GPT, EFI-only, single-disk layout viahomelab.disko.device(default/dev/sda) andhomelab.disko.rootSize(default100G). Three partitions, all mounted by GPT partlabel rather than filesystem label:disk-main-ESP(512M vfat β/boot),disk-main-nixos(root, ext4 β/),disk-main-storage(remainder, ext4 β/mnt/disks,nofail). Only k8s nodes use disko (it's pulled in byprofiles/k8s-node.nix); Pi/image hosts partition via theirsd-image/netbootinstaller modules instead.oldschoolraisesrootSizeto200Gβ headroom for the harmonia binary-cache/remote-builder role stacked on top of Docker, the Actions runner, andyarr.Hosts installed before partlabel-based disko mount by filesystem label instead and fail to find root at boot until relabeled.
nix/scripts/disko-partlabel-check.sh(read-only audit) anddisko-partlabel-migrate.sh(sgdiskGPT-name rewrite only β never touches filesystem data) handle that in place, no reinstall needed.The install ISO (below) drives disko through a
homelab-install <host>wrapper that reads the target host'shomelab.disko.devicestraight out of the flake before partitioning.
Secrets: sops-nix
nix/system/sops.nixwires insops-nix, but decrypts with the host's own SSH ed25519 host key (age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ]) rather than a shared fleet-wide age key β a compromised host only exposes secrets scoped to that host.It's imported by four host files today:
optiplexandretrofit(each withsops.defaultSopsFile = nix/secrets/<host>.sops.yamland ak8s-sa-signing-keysecret owned bykubernetes:kubernetes, feeding the FML ServiceAccount-issuer cutover inservices.k8s),oldschool(harmonia-cache-key; the cache's public signing half is committed in the clear atnix/secrets/oldschool-harmonia-cache.pub), andforge(same pattern,nix/secrets/forge.sops.yamlandnix/secrets/forge-harmonia-cache.pub).nix/secrets/holds one.sops.yamlper host that uses it.
Auto-upgrade
nix/system/nixos.nixsetssystem.autoUpgrade.enable = lib.mkDefault true, pullinggithub:jonpulsifer/infra(main) daily at03:37with up to an hour of random delay and-Llogging. This is on by default for every host built throughmkHostunless a host overrides it.Because it's a default, not a hard-coded
true, microSD-rooted hosts turn it off:nix/hardware/pi4/default.nixcovers the Pi 4 fleet, whilenix/profiles/pi-zero.nixcoversradiopi0andblinkypi0because there is no armv6l builder or binary cache. Their generations are built elsewhere and pushed withnixos-rebuild ... --target-host.nix/images/container.nixalso forces it off (lib.mkForce false) β an ephemeral container image has no business rebuilding itself.The practical consequence: a config deployed from a branch to a host that still auto-upgrades gets silently reverted on the next
03:37cycle unless the branch merges tomainfirst.
Image outputs
flake.nixbuilds five extranixosConfigurationsthrough the samemkHost/mkImagemachinery, each backed by a module undernix/images/, alwayssystem = "x86_64-linux":wslβnixos-wslmodule, Docker Desktop interop, andboot.binfmt.emulatedSystems = [ "aarch64-linux" ]so a WSL/laptop dev box can cross-build the PisdImageoutputs via qemu-user binfmt without a native aarch64 builder.isoβinstallation-cd-minimal+hardware/x86, on thefleetbaseline; shipsdiskoand ahomelab-install <host>wrapper (nix/images/homelab-install.sh, MOTD points atnix/images/INSTALL.md) that reads the target'shomelab.disko.deviceout of the flake, then runsdisko --mode destroy,format,mountandnixos-install, both againstgithub:jonpulsifer/infra#<host>by default (a branch or local checkout can be substituted).gceβvirtualisation/google-compute-image.nix; also the basenix/hosts/oldboy.nix(the GCE VM host) imports, which is all that host declares β thefleetbaseline supplies the rest. Sogcedoubles as both a buildable generic image andoldboy's hardware profile.containerβvirtualisation/docker-image.nix, hostnamepulse, auto-upgrade and sshguard forced off; shipscurl/wget/jq/htopfor AI-agent dev use.netbootβinstaller/netboot/netboot-minimal.nix+hardware/x86, on thefleetbaseline: a generic x86 PXE rescue/install image. Distinct fromspore's always-on PXE server (nix/services/pxe-netboot.nix) and from therackpi5HTTP/RAM boot chain used by forge's EEPROM fallback β this one is a buildable artifact, not a host.
packages.x86_64-linuxre-exports the buildable artifacts flatly, derived from each registry entry'sartifactfield: the five images above, plussdImage/piBootImgworkflow aliases forcloudpi4,homepi4,weatherpi4,capsule,spore,rackpi5(image-only), andforge. Interactive ARM host builds targetnixosConfigurations.<host>.config.system.build.toplevelthroughmise run nix:buildand run on forge.radiopi0andblinkypi0'ssdImagepackages live underpackages.aarch64-linux(packageSystemin the registry), since their armv6l cross-build needs an aarch64 build platform.The
nix-image-builderGitHub Actions workflow (manualworkflow_dispatch) builds a subset on demand:container,gce,iso,oldboy,cloudpi4,homepi4,weatherpi4,radiopi0,blinkypi0,wsl. Pi targets run on nativeubuntu-24.04-armrunners; the rest on standard runners.gce/oldboy/wslupload straight to GCS; the others upload as workflow artifacts retained for one day.capsule,spore,forge, and the legacyrackpi5image are not in this workflow's choices.
Cross-compiling the armv6l Pi Zeros
radiopi0andblinkypi0are the original Pi Zero W (BCM2835, single-core armv6l). Nonixos-hardware/nixos-raspberrypiboard module goes back that far, and there's no armv6l binary cache, sonix/hardware/pi0.nixsetsnixpkgs.buildPlatform.system = "aarch64-linux"withnixpkgs.hostPlatform = lib.systems.examples.raspberryPiβ a real cross-compile (build machine stays its own native arch), not QEMU emulation. Becausenixpkgs.buildPlatformis pinned toaarch64-linux, both hosts'nixosConfigurationsentries inflake.nixare also declared withsystem = "aarch64-linux"to match β the actual target is still armv6l.The stock Pi kernel config doesn't cross-build cleanly for this chip (some Pi 4/5 and DesignWare-I2C drivers it pulls in emit 64-bit division calls the armv6l kernel linker can't resolve), so
pi0.nixcarries astructuredExtraConfigkernel patch that strips those plus camera/media/Bluetooth/CAN/NFC/PCMCIA/SATA/RAID support neither board needs, keeping MMC, USB, wifi, GPIO, SPI, BCM2835 I2C, and ASoC audio.Both hosts build on
forge(whose native arch is aarch64-linux, matching the cross build platform above) and are pushed withnixos-rebuild ... --target-host, never built on-device;system.autoUpgrade.enable = falsefor both innix/profiles/pi-zero.nix, which also keeps them on scripteddhcpcdrather than the baseline'ssystemd-networkd.blinkypi0is unplugged. Its config mirrorsradiopi0.nixand is unverified against live hardware β check the wifi and board specifics against the real Pi before deploying to it.
forge: NVMe-boot build hostforgeboots off its installed NVMe (256 GB Patriot P300) and runsservices.buildHost(nix/services/build-host.nix) β Nix remote builder, docker + buildx for native arm64 OCI, and a harmonia binary cache fronted by nginx on the lab VLAN.nix/hosts/forge.nixis the host config; it importsnix/profiles/pi5-nvme.nix+nix/services/build-host.nix+nix/system/sops.nixand usessdImage.expandOnBoot = true(no third partition; the NVMe is dedicated to root, unlike spore'sgrow-root-and-partition-storageservice which reserves the disk tail for/nfs/data).The spore-side
spore-native-boot-rackpi5.servicepublisher supplies forge's EEPROM fallback.nix/hosts/rackpi5.nixis the minimal image-only config that pins the squashfs digest into the signed boot command line;nix/services/spore-native-boot.nixatomically publishes the stableboot.img/boot.sigpaths and retains the matching squashfs at/<digest>.squashfs. Theservices.spore.nativeBootTargets.rackpi5block innix/lib/registry.nixwires the two configurations.The EEPROM's own boot-order configuration lives outside the Nix closure and is applied by hand with
rpi-eeprom-config --editβ a stock EEPROM firmware update erases the enrolled signing key for the legacy HTTP path, so it needs re-enrolling before the next reboot after any such update.forge's sops materializes a harmonia cache signing key (sops.secrets."harmonia-cache-key"); the public half is committed in the clear atnix/secrets/forge-harmonia-cache.pubso clients can pin it in theirnix.settings.trusted-public-keys. Cache URL once clients opt in:http://forge.lolwtf.ca.
Cluster-topology SSOT feeding into Nix
nix/services/k8s/networks.nixreadsclusters/folly/config/cluster-topology.jsonandclusters/offsite/config/cluster-topology.jsonwithbuiltins.fromJSON, parsing the API-server port to an int and splitting the comma-separated DNS list β the same ConfigMap JSON Flux applies.nix/services/k8s/default.nixconsumes that formasterAddress,clusterCidr,serviceCidr,kubelet.clusterDns, and the staticapiServerIP/apiServerHostnamehost entry.nix/services/nfs-server.nix(spore) reads the samefollytopology fornodeCidrandlbRangeto scope its NFS export ACLs to the real node subnet and Cilium LB VIP pool.nix/lib/lab.nixprojectsclusters/folly/config/lab-topology.json, the same flat-string ConfigMap Flux and OpenTofu consume. Host and service modules import that helper for Lab/future CIDRs and host addresses.nix/lib/fleet.nixprojects a third,terraform/network/tailscale/fleet.tf.json(.locals.fleet): the tailnet MagicDNS domain and the public DNS zone, plus the OIDC issuer host and kiosk hub URL derived from the zone. The tailscale Terraform root auto-loads the same file. Consumers arenix/lib/apps.nix,nix/services/k8s/default.nix,nix/services/yarr.nix,nix/services/coredns-sinkhole.nix,nix/system/ddnsd.nix,nix/profiles/pi4-kiosk.nix, andnix/hosts/forge.nix.None of these files hardcode an address or a domain β change the JSON, and Nix, Terraform, and Flux all pick it up.
Checks
nix/lib/checks.nixsupplieschecks.x86_64-linux, evaluated bynix flake check. They are eval-time assertions over every configuration in the registry β seconds, no builder, no hardware β and they are where a cross-host coupling gets stated instead of left to convention.fleet-hosts-evaluateβ every configuration in the registry still evaluates.k8s-cluster-ca-certsβ a cluster withservices.k8s.clusterCaenabled has its Terraform PKI outputs present underterraform/pki/certs. Nix path interpolation is lazy, so a missing cert for a cluster that has not enabled the CA yet would otherwise fail nothing until the day it does.k8s-control-plane-sa-signing-keyβ every control-plane node declares the sops secret its apiserver reads to sign service-account tokens.fleet-unique-uidsβ no host has two normal users sharing a uid.spore-reliabilityβ the signed initrd uses a digest-addressed squashfs, nginx only softly depends on the publisher, dnsmasq's TFTP transfer range matches the firewall, storage gates registration/NFS, and the HTTP probe emits its metric.
CI additionally builds full closures for a subset (
.github/workflows/nix-ci.yaml); the checks cover the whole registry at evaluation depth.
Not here
Actual
nixos-rebuild/nix buildinvocations, adding a new host, rollback, and the auto-upgrade branch-revert caveat in command form: Runbooks/Deploy a NixOS Host.
Linked references 7
Use this when building, deploying, or rolling back a NixOS host from this repo. Host inventory lives in Fleet; architecture background lives in Architecture/NixOS.
Unplugged. In the flake as nixosConfigurations.blinkypi0 (nix/hosts/blinkypi0.nix, sharing nix/hardware/pi0.nix with Fleet/radiopi0 β same armv6l cross-compile constraints, see Architecture/NixOS).
Step-by-step: Runbooks/Deploy a NixOS Host. Host inventory: Fleet. Layer background: Architecture/NixOS.
In the flake as nixosConfigurations.radiopi0 (nix/hosts/radiopi0.nix + nix/hardware/pi0.nix, shared with Fleet/blinkypi0). armv6l has no upstream binary cache and no board-support module, so Fleet/forge supplies its aarch64 cross-build platform β see Architecture/NixOS. Config is minimal: tailscale + ssh + wiringpi only.
Layer 1 β Bare metal (Architecture/NixOS)
Configured in nix/hosts/. See Architecture/NixOS for how they build.
Bare metal β Architecture/NixOS configuration for every host, deployed with nixos-rebuild and kept honest by auto-upgrades from main.