This repo is a Nix flake that manages most of my setup on macOS and fully manages machines I have that run NixOS as their operating system. It also contains as much configruation as I can make work on other Linux distros such as Ubuntu.
flake.nixdefines inputs, outputs, and instantiates host configurations vialib/functionslib/contains helper functions:mkNixosHost- constructs NixOS system configurationsmkDarwinHost- constructs nix-darwin system configurationsmkHomeConfig- constructs Home Manager configurationsgenebeanLib- shared platform-detection helpers passed to all modules viaextraSpecialArgs
modules/contains Nix modules organized by type:modules/genebean/- reusable, option-driven modules under thegenebean.*namespacemodules/genebean/home/- Home Manager modules; exposed ashomeManagerModules.genebeanmodules/genebean/darwin/- nix-darwin companion modules; exposed asdarwinModules.genebean
modules/shared/- shared modules imported by multiple hostsmodules/shared/home/general/- Home Manager config for all GUI usersmodules/shared/home/linux/- Home Manager config for Linux-specific appsmodules/shared/nixos/- NixOS modules (i18n, flatpaks, restic, etc.)
modules/hosts/- host-specific configurationsmodules/hosts/nixos/- NixOS host configs and hardware configsmodules/hosts/nixos/tcan-left/hermes/- the Hermes AI agent fleet (one systemd-nspawn container per agent persona); see that directory'sagent-names.nixfor the roster
modules/hosts/darwin/- macOS host configsmodules/hosts/home-manager-only/- Home Manager-only configs
The Hermes roster is a standalone list of names because it must be available
before either the NixOS or Home Manager module configuration is evaluated.
mkNixosHost automatically loads
modules/hosts/nixos/<hostname>/hermes/agent-names.nix when it exists,
defaulting to an empty list otherwise. It derives each
hermes/<name>/home.nix module path from that list, imports those modules
into Home Manager, and passes the same names to the NixOS companion as a
recursion-safe index. All agent configuration still goes through
genebean.services.hermesAgents on the Home Manager side.
Local changes are applied with the nixup/nixdiff aliases (see AGENTS.md
for the exact commands they wrap). For deploying to a remote host without
SSHing in and running nixup by hand, use
deploy-rs:
nix run .#deploy-rs -- .#<host> # build, copy, activate, confirm
nix run .#deploy-rs -- .#<host> --dry-activate # preview without switchingEvery node in deploy.nodes (flake.nix) builds itself (remoteBuild = true), so this can be run from any machine that can reach the target over
SSH - not just mightymac. Hostnames are resolved via Tailscale MagicDNS, so
no domain needs to be configured anywhere in this repo.
deploy-rs's automatic rollback (magic-rollback) is the main reason to use it
over a manual nixos-rebuild switch --target-host: if the new generation
breaks SSH connectivity, it rolls back on its own - important for
kiosk-entryway and kiosk-gene-desk, which have no keyboard normally
attached and would otherwise need a physical visit to recover.
--skip-checks is required when deploying from mightymac specifically:
deploy-rs's own pre-deploy nix flake check tries to build every system's
derivations locally regardless of any node's remoteBuild setting, and
mightymac can't build x86_64-linux at all (no local builder for it). Run
nix flake check separately beforehand instead - it validates the same
things without attempting to build anything locally that can't be.
Scope: all NixOS hosts plus mightymac. AirPuppet/Blue-Rock and
home-manager-only hosts stay on their existing darwin-rebuild/home-manager switch workflows.
This repo uses the following tools for code quality:
- deadnix - Finds unused code in Nix files.
- nixfmt - Formats Nix files. Run
nix fmt .to format all files. - ruff - Lints and formats Python files (e.g.
pkgs/*/generator.py-style scripts). - statix - Checks Nix code for common issues and style problems.
Pre-commit hooks are configured in .pre-commit-config.yaml and run automatically before commits.
Run pre-commit install after checkout to make sure it gets used.
CI validation is defined in .github/workflows/validate.yml and mirrors what is done by pre-commit.
docs/genebean-options.md is generated from the genebean.* option declarations
(the descriptions in every mkOption) via pkgs.nixosOptionsDoc - the same
mechanism NixOS/home-manager/nix-darwin use for their own options references.
Run nix run .#update-docs after changing any genebean.* option to
regenerate it; nix build .#genebean-options-doc alone just leaves the
rendered Markdown in the Nix store without writing it back into the repo.
This repo historically contained my dot files. Historically symlinked files on Windows are still in windows/. Everything else is just in git history now.
Sometimes hosts, or their storage, need replacing... sepcially ones that run on SD cards like kiosk-gene-desk. When that time comes, here is how to get it back up and running.
These hosts use disko for partitioning, impermanence for a tmpfs root
(wiped every boot, with an explicit persist allowlist), and
nixos-anywhere for install - not a flashed system image. disko wipes
the target disk from scratch every time, so this is the same process for
a first-time install and a full replacement (dead SD card, etc).
-
Build the installer image (on mightymac, or anywhere with the flake):
nix build .#rpi4-installer. This is a genericnixos-raspberrypiinstaller image, pre-decompressed so Raspberry Pi Imager can flash it directly - it silently corrupts the card if you hand it the raw.img.zstinstead, since Imager doesn't decompress zstd itself.resultis the flashable.imgfile. -
Flash it to a USB drive, not the SD card - Raspberry Pi Imager, "Use custom image", pick
result. -
Connect a display and boot the Pi from that USB drive with the SD card removed. Both matter:
nixos-anywhere/diskocan't repartition a disk the system is currently running from, and a Pi's boot order can prefer the SD card over USB if one's already inserted - booting from USB with no SD card present avoids that ambiguity entirely. A display is required too: the installer sets a random root password on boot and prints it on screen along with its IP - read both off the display. You'll need the password again fornixos-anywherein step 5. Once it's up, insert the SD card - it's just a second, currently-empty disk to the running installer at this point, safe to insert at any time after boot. -
Seed the bootstrap files.
diskowipes/persistfrom scratch, so the sops age key has to exist on the target beforenixos-install's activation runs, orsops-install-secretsfails on first boot with no way to recover short of a manual SSH patch. From the repo root:scripts/prep-install-bootstrap.sh <hostname>
This derives the age key from a per-host SSH key already in
modules/shared/secrets.yaml, seeds a clock file so the fresh boot's NTP sync starts from a real floor instead of the image build's fallback date (no RTC on these Pis), and tags the last 5 restic snapshots already in the backup repo for that hostname aspre-reinstall, so they survive the fresh install's own backup rotation - this talks to the repo directly (mightymac is already a valid recipient for the restic secrets), so it works regardless of whether the host being replaced is still reachable. Confirm the derived age recipient printed at the end matches the host's entry in.sops.yamlbefore continuing. -
Install:
nixos-anywhere --flake ~/repos/dots#<hostname> \ --extra-files ./nixos-anywhere-extras-<hostname> root@<installer-ip>
It'll prompt for the root password from step 3 to make its initial SSH connection, then reboots into the real system when done.
-
Restore state. SSH in once it's back up, then:
sudo kiosk-restic-full-restore # lists available snapshots sudo kiosk-restic-full-restore <id> # restores from a specific one
Use the specific
pre-reinstall-tagged snapshot ID from step 4, not whatever's newest - a boot-triggered catch-up backup on the fresh install can capture empty state and become "latest" before you get a chance to restore, so the tool never guesses for you. This restores Home Assistant'shass-browser_modchromium registration, atuin's sync-server login, and the tailscale node identity, and stops/starts the affected services itself. -
Verify: chromium shows the kiosk page (browser_mod registered in Home Assistant),
atuin statusshows a recent sync,tailscale statusshows the expected node name (if it comes up suffixed like<hostname>-1, the old pre-replacement node is probably still listed as active in the Tailscale admin console under the plain name - remove it there).
The directions below are all a bit dated and likely incomplete 😔 They will be updated as time make practical.
- run
xcode-select --installto install the command-line developer tools (this includes the Apple's stock version of Git). - create ed25519 ssh key via
ssh-keygen -t ed25519 - add key to GitHub account
- run macOS graphical installer from https://determinate.systems/posts/graphical-nix-installer
- run
mkdir ~/repos - run
cd ~/repos - run
git clone git@github.com/genebean/dots - create keys for SOPS via
mkdir -p ~/Library/Application\ Support/sops/age && nix run nixpkgs#ssh-to-age -- -private-key -i ~/.ssh/id_ed25519 > ~/Library/Application\ Support/sops/age/keys.txt && nix run nixpkgs#ssh-to-age -- -i ~/.ssh/id_ed25519.pub >~/Library/Application\ Support/sops/age/pub-keys.txt - run
cat ~/Library/Application\ Support/sops/age/pub-keys.txt |pbcopy - edit
.sops.yamland:- paste copied data into a new line under keys
- add creation rule
- add to common rule
- run
mkdir modules/home-manager/hosts/$(hostname -s) - run
nix run nixpkgs#sops -- modules/home-manager/hosts/$(hostname -s)/secrets.yaml - Add entries for
local_private_envcontaining anything you want exported as env vars or local aliases that you want to keep privatetailscale_key
- create
modules/home-manager/hosts/darwin/$(hostname -s)/<username>.nixbased on needs for this machine - run
mkdir modules/hosts/darwin/$(hostname -s) - create
modules/hosts/darwin/$(hostname -s)/default.nixbased on need for this machine - add entry to
flake.nix - if not a fresh install of macOS,
- run
brew leavesand look for things installed from taps you don't want any more - uninstall the program and the tap if not adding it to nix
- run
- run
git add . - run
git status- it should look something like this:gene.liverman@mightymac dots % git status On branch main Your branch is up to date with 'origin/main'. Changes to be committed: (use "git restore --staged <file>..." to unstage) modified: .sops.yaml modified: flake.nix new file: modules/home-manager/hosts/mightymac/gene.liverman.nix new file: modules/home-manager/hosts/mightymac/secrets.yaml new file: modules/hosts/darwin/mightymac/default.nix
- run
sudo mv /etc/nix/nix.conf{,.before-nix-darwin} - run
sudo mv /etc/zshenv{,.before-nix-darwin} - run
nix run --extra-experimental-features 'nix-command flakes repl-flake' nix-darwin -- check --flake ~/repos/dots - Run
nix run --extra-experimental-features 'nix-command flakes repl-flake' nix-darwin -- switch --flake ~/repos/dots- if prompted, run
sudo mv /etc/shells{,.before-nix-darwin} - if prompted, run
sudo mv /etc/zshenv{,.before-nix-darwin} - if prompted, you may also have to move or remove
~/.zshrc - on the first (or several) run(s) homebrew may well fail due to previously installed casks or programs in
/Applications. You may have to runbrew install --force <package name>to fix this - you may have to run brew multiple times to fix things
- if prompted, run
- in Settings > Privacy & Security > App Management you will need to allow iTerm
- After the nix command finally works, open a new iTerm window and it should have all the nixified settings in it.
- Go into iTerm2's preferences and use the Hack Nerd Mono font so that the prompt and other things look right. You will likely also want to adjust the size of the font.
- run
sudo cp /etc/pam.d/sudo_local{.template,}- this will generate a popup asking permission - run
sudo nvim /etc/pam.d/sudo_localand uncomment line as directed by top comments - save via
!wwhich will generate a popup asking permission
Nix installs and configures Atuin, but you still need to log into the server:
- run
atuin import autoto import the shell history from before Atuin was installed and running - run
read -s akeyand enter the encryption key - run
read -s apassand enter the user password - run
atuin login --key=$akey --password=$apass --username=gene
This is the verified, current procedure - confirmed end-to-end while onboarding
tcan-left (a MacPro6,1).
-
Build the installer ISO - the same one every time, bcachefs support included regardless of what the target actually uses:
nix build .#bcachefs-installer-isoFlash
result/iso/*.isoto a USB drive (dd, Balena Etcher, Rufus - any of them, it's a standard ISO). -
Boot the target from that USB, connected to ethernet, no SD card or other disks that shouldn't be touched still attached. A display and keyboard are required for this step: the console prompts you to type a password for
root- note it down, you'll need it for SSH. Find the IP it got via DHCP either on the console itself, or via your router/switch's DHCP client list (UniFi, etc.) by matching the new device that appears around boot time. Set a DHCP reservation for that MAC address now - a later reboot can otherwise land on a different IP mid-install. -
Inspect the live installer to gather real hardware facts:
ssh root@<installer-ip> "lsblk -o NAME,SIZE,TYPE,MODEL,TRAN && ip -brief link && nproc && free -h && ls /sys/firmware/efi 2>&1 | head -1" ssh root@<installer-ip> "ls -la /dev/disk/by-id/ | grep -v -- '-part'"
Use the stable by-id disk path from the second command, not
/dev/sda- the boot USB itself shows up as a separate disk and must never be a disko target. -
Generate
hardware-configuration.nix:ssh root@<installer-ip> "nixos-generate-config --no-filesystems --show-hardware-config" > modules/hosts/nixos/<hostname>/hardware-configuration.nix
-
Write
disk-config.nix(disko) using the by-id path from step 3. Seemodules/hosts/nixos/tcan-left/disk-config.nixfor a bcachefs example (3-subvolume split:/,/nix,/var/lib). If using bcachefs, every bcachefs-typed partition needs an explicit, non-emptylabel- disko always passes--label=${label}tobcachefs format, and a blank one fails with a crypticInvalid argument (os error 22). -
Create
home-<username>.nix-mkNixosHosthard-requiresmodules/hosts/nixos/<hostname>/home-${username}.nixto exist (the path is unconditional inlib/mkNixosHost.nix); the build fails witherror: Path '.../home-gene.nix' does not exist in Git repositorywithout it. Seemodules/hosts/nixos/tcan-left/home-gene.nixfor a minimal starting point. Also wire basic metrics now, not as an afterthought - copymodules/hosts/nixos/kiosk-entryway/monitoring.nix(self-contained: localvmagentscrapes the host's own node-exporter and remote-writes to nixnuc's VictoriaMetrics using the existing sharedvmagent_push_pwsecret, no per-host secret needed) and import it fromdefault.nix. Check also whethernixos-hardwarehas a profile for the exact board (github:NixOS/nixos-hardware- module names follow the directory path with/replaced by-, e.g.aoostar/r1/n100→aoostar-r1-n100); wire it viaadditionalModulesin theflake.nixstep below (bigboyalready does this forlenovo-thinkpad-p52). -
Pull the installer's SSH host key and adopt it as the target's permanent one. This is safe because the installer's root is
tmpfsand generates a genuinely fresh key every boot - confirm that before trusting it:ssh root@<installer-ip> "findmnt /"should showtmpfs, andreadlink -f /etc/ssh/ssh_host_ed25519_keyshould show a plain file, not a symlink into/nix/store.mkdir -p nixos-anywhere-extras-<hostname>/etc/ssh ssh root@<installer-ip> "cat /etc/ssh/ssh_host_ed25519_key" > nixos-anywhere-extras-<hostname>/etc/ssh/ssh_host_ed25519_key ssh root@<installer-ip> "cat /etc/ssh/ssh_host_ed25519_key.pub" > nixos-anywhere-extras-<hostname>/etc/ssh/ssh_host_ed25519_key.pub chmod 600 nixos-anywhere-extras-<hostname>/etc/ssh/ssh_host_ed25519_key
-
Derive the age recipient and register it in
.sops.yaml:nix run nixpkgs#ssh-to-age -- -i nixos-anywhere-extras-<hostname>/etc/ssh/ssh_host_ed25519_key.pub
Add a
&system_<hostname>anchor with that recipient, acreation_rulefor<hostname>/secrets.yaml$, and add it tomodules/shared/secrets.yaml's key group. Then:sops updatekeys modules/shared/secrets.yaml
Optional but recommended: escrow the private key too, so you don't lose the host identity if the disk ever dies (a blind
sops setso the value never prints):sops set modules/shared/secrets.yaml '["ssh_host_ed25519_key_<hostname>"]' "\"$(cat nixos-anywhere-extras-<hostname>/etc/ssh/ssh_host_ed25519_key)\""
-
Create the host's own first
secrets.yaml(see Notes for why this is plainsops, notsops set):sops modules/hosts/nixos/<hostname>/secrets.yaml
If your own machine's key isn't a recipient for this file yet (per-host secrets are scoped to just that host's own system key), point
sopsat the host's key temporarily instead:nix run nixpkgs#ssh-to-age -- -private-key -i nixos-anywhere-extras-<hostname>/etc/ssh/ssh_host_ed25519_key > /tmp/<hostname>-age-key.txt SOPS_AGE_KEY_FILE=/tmp/<hostname>-age-key.txt sops modules/hosts/nixos/<hostname>/secrets.yaml rm /tmp/<hostname>-age-key.txt
-
Register authorized SSH keys for your user.
private-flakeis a separate, private companion repo (already cloned at~/repos/private-flake) that holds sensitive config this repo never commits directly - see the "Private Flake" section ofAGENTS.md. Add the new host to itsmodules/nixos/ssh-keys.nix(sshKeyHosts.<hostname>). -
Wire the host into
flake.nix:tcan-left = localLib.mkNixosHost { hostname = "tcan-left"; };
And a matching
deploy.nodes.<hostname>entry withremoteBuild = true;(see the existing entries for the exact shape), if you want remote deploys. -
Validate before touching the real hardware:
git add modules/hosts/nixos/<hostname> flake.nix .sops.yaml modules/shared/secrets.yaml nix build .#nixosConfigurations.<hostname>.config.system.build.toplevel --no-link --print-out-paths
If you edited
private-flake(step 10), pointflake.nix'sprivate-flake.urlat"path:/home/gene/repos/private-flake"temporarily andnix flake lock --update-input private-flake- keep this in place through the actual install and verification (step 15), not just until the branch is pushed. Only once the host is up and verified working: mergeprivate-flake's PR, revertflake.nix'sprivate-flake.urlback to"github:genebean/private-flake", re-lock against that real merged commit, and opendots's own PR. -
Pre-authorize a throwaway key so install doesn't need password auth.
<console-password>is the root password from step 2:ssh-keygen -t ed25519 -N "" -C "bootstrap" -f /tmp/bootstrap-key SSHPASS='<console-password>' sshpass -e ssh root@<installer-ip> \ "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys" < /tmp/bootstrap-key.pub
-
Install. Make sure no earlier attempt against this same host is still running in the background first (see Notes) - then:
nix run github:nix-community/nixos-anywhere -- \ --flake .#<hostname> \ --extra-files ./nixos-anywhere-extras-<hostname> \ --phases disko,install,reboot \ -i /tmp/bootstrap-key \ root@<installer-ip>
-
Verify: SSH in as your normal user once it reboots, confirm
systemctl status tailscaled-autoconnect, and thattailscale statusshows the new node. -
Deploy via
deploy-rsgoing forward:nix run .#deploy-rs -- .#<hostname> --dry-activate --skip-checks nix run .#deploy-rs -- .#<hostname> --skip-checks
sops setcannot bootstrap a brand-new file - confirmed twice (the file genuinely absent, and an empty{}stub both fail:Error reading file: ... no such file or directory/sops metadata not found). Plainsops <file>(edit mode, no subcommand) does create one: on a nonexistent path it opens$EDITORon an empty temp document and encrypts whatever's saved, resolving recipients from.sops.yaml'screation_rulesame as any other file. No interactive terminal needed either - point$EDITORat a script that writes to the path it's given ($1) and it works non-interactively.- Custom ISO +
root, not the stock installer. The ISO above is built fromnixos-images'image-installermodule - the same media every time, with whatever kernel modules the target needs already baked in. It logs in asroot, notnixos(the stock installer's account). - Why not
nixos-anywhere's default kexec bootstrap. Its default is tokexecfrom whatever's running into its own bundled image, so you'd never need to pre-build/flash anything - but on this specific hardware (MacPro6,1)kexecitself hung mid-transition, twice, rather than completing the handoff (a known class of issue with some Mac EFI implementations - if a kexec'd target doesn't come back within roughly a minute, treat it as hung, not slow: check the display, power-cycle, start over). Separately, its bundled image doesn't carry the bcachefs module, and even a custom--kexecimage gets silently skipped on a retry unless you also pass--force-kexec(nixos-anywhereassumes "already in a compatible installer" means "don't bother re-kexec'ing"). Booting the real target image directly over USB sidesteps both problems. If you'd rather use kexec anyway (faster when it works): build withpkgs/bcachefs-kexec-installerinstead, pass--kexec ./result/....tar.gz --force-kexec, and keep the defaultkexec,disko,install,rebootphases.--kexecalso accepts anhttp(s)://URL - the target fetches it itself viawget, so hosting one on a LAN-reachable nginx avoids re-uploading a large file every time. - The installer's SSH host key is genuinely fresh per boot. Its root is
tmpfs(verify withfindmnt /), andnixos-images/the stock installer both generate a newssh_host_ed25519_keyevery boot. Confirm it's really fresh (not baked into the image) before adopting it: mtime should match your session, andreadlink -f /etc/ssh/ssh_host_ed25519_keyshould show a plain file, not a symlink into/nix/store. Once confirmed, this is why step 6/7 works without generating a separate key - that's whynixos-anywhere --extra-filesseeding it to/etc/ssh/ssh_host_ed25519_keygives the fleet-widesops.age.sshKeyPathsdefault (modules/hosts/nixos/default.nix) something to decrypt secrets with from the very first boot, no two-phase post-install dance needed. This only applies to a normal persistent-root host -kiosk-gene-desk(wipe-every-boot impermanence) has a separate, already-solved version of this problem; seescripts/prep-install-bootstrap.sh. --env-password(sshpass -e ssh-copy-id) is unreliable in practice - confirmed repeatedly loopingPermission denied/Too many authentication failureseven with the right password, on an install where a plainsshpass -e sshwith that same password worked fine moments earlier. Step 12/13's pre-authorized-key approach avoidsssh-copy-identirely.- Never run two
nixos-anywhereattempts against the same target at once. A stale/forgotten background attempt keeps retrying its own auth in parallel and can stall the one that's actually working - confirmed: a real install'snix copyfroze solid for several minutes while an orphaned duplicate kept failingssh-copy-idagainst the same host in the background. Checkps aux | grep nixos-anywherebefore assuming a stuck install is just slow. - A GUI askpass helper (
genebean.programs.askpass/ksshaskpass) can make a plainssh/sshpasscommand (e.g. the inspection steps above) hang on a prompt it can't parse non-interactively, instead of using$SSHPASS. PointSSH_ASKPASSat a trivial script rather than disabling askpass outright -SSH_ASKPASS_REQUIRE=neveralso disables the legitimate password fallback and breaks auth entirely:printf '#!/bin/sh\necho "$SSHPASS"\n' > /tmp/askpass.sh && chmod +x /tmp/askpass.sh SSHPASS='<password>' SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh root@<installer-ip> ...
- Always pass
--skip-checkstodeploy-rswhen running it from a laptop/desktop, not just frommightymacas the existing note under Deploying states - its pre-deploynix flake checkbuilds every node's full closure locally regardless of that node's ownremoteBuildsetting, not just the one you're targeting. Forgetting this genuinely OOM'd a 14GB-RAM laptop in practice. - bcachefs is out-of-tree again (kernel maintainer conflict, back to a
DKMS-style module) -
bcachefs-toolsbeing present doesn't mean the kernel can mount one; a stock ISO canbcachefs formatfine but then fails withmount: unknown filesystem type 'bcachefs'.pkgs/bcachefs-installer-iso'sboot.extraModulePackages = [ config.boot.kernelPackages.bcachefs ];fixes this - referencingconfig.boot.kernelPackagesrather than a hardcoded version keeps it self-matching whatever kernel the image ships, since out-of-tree modules are ABI-locked to one exact kernel build. diskoalways emits--label=${label}tobcachefs format, even for a single-disk filesystem. An unset (empty-string-default)labelproduces a literally empty--label=argument, whichbcachefs-toolsrejects witherror creating disk path: Invalid argument (os error 22). Always set an explicit, non-emptylabelon every bcachefs-typed partition (seetcan-left/disk-config.nix).