- Nix 94.1%
- Python 3.4%
- HTML 1%
- CSS 0.9%
- JavaScript 0.6%
| home-manager | ||
| hosts | ||
| pkgs | ||
| .gitignore | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
NixOS configurations
NOTE: Code moved to https://git.firecat53.me/firecat53/nixos. Issues and PRs still accepted here for now. Github repo maintained as a read-only mirror.
Machines
- Laptop
laptop - Homeserver
homeserver - Backup server
backup - VPS cloud server
vps - Office secondary/spare desktop
office - Examples:
- Flake install w/ home-manager and sops.
- Encrypted or unencrypted
base-btrfsorbase-zfs
- Encrypted or unencrypted
- Bare minimum flake install for testing.
base-minimal
- Flake install w/ home-manager and sops.
- Installer/rescue ISO
hosts/installer/iso- custom auto-build monthly into /mnt/downloads/iso for use with the trusty-boot-key project. - Stage-1 install target
minimal(hosts/installer/minimal) - bootable, no sops, no home-manager. Gets a bare-metal rebuild far enough along to run the real config. Itshardware-configuration.nixis a placeholder, regenerated during the install. See Installing locally.
Emergency restore kit
The trusty-boot-key USB carries the ISO plus the nixos/, dotfiles/ and
shared/ trees, refreshed by flash_drive_update.sh.
Any secrets on the drive are all encrypted in some way. Age secret key stored in the password vault(s).
Building the system with sops-nix does not need forgejo — use
--override-input my-secrets path:<path/to/nixos-secrets against the copy on
the drive.
Update flow
forgejo is the transport and main is the deploy branch: every host pulls it
by URL, and only the desktops (laptop, office) keep a checkout, for editing
and on-demand rebuilds. flake.lock is committed, so main records exactly
what the fleet runs.
- Lock bumps: the
flake-lock-updatetimer onhomeserver(hosts/homeserver/services/flake-lock-update.nix) runsnix flake update --commit-lock-fileat 0400 daily in its own clone under/var/lib/flake-lock-update, builds every host's toplevel, and only then pushesmain. A broken input fails the unit instead of reaching all five hosts at once:mainstays on the last good lock, the 0440 upgrades are no-ops, and prometheus alerts on the failed unit. Each build is held by aresult-<host>symlink, which roots the current toplevel for the binary cache below and lets the weekly gc reclaim only superseded ones. - All five hosts auto-upgrade at 0440 from
git+https://git.firecat53.me/firecat53/nixos.git?ref=main. That repo is public, so the config side needs no credentials. - Secrets: the
my-secretsflake input points at the privatenixos-secretsrepo over ssh. Each host authenticates with its own/etc/ssh/ssh_host_ed25519_key, registered on that repo as a read-only deploy key — the same key sops-nix already uses as the host's age identity, so it grants nothing the host didn't already have. The ssh alias lives in/etc/ssh/ssh_config(hosts/modules/common/sshd.nix) rather than~/.ssh/config, becausenixos-upgradeevaluates the flake as root. A newly built host needs its deploy key added before its first build. - Deploy: merge to
mainand push. Hosts pick it up at with the auto-upgrade or use the immediate path below. - Rollback:
git revert+ push, ornixos-rebuild --rollbackon the affected host when you need it now.git log -p flake.lockonmainis the audit trail of what every host ran on a given day.
Note the whole path depends on git.firecat53.me, which resolves to the VPS
and proxies back to homeserver over wireguard — so a VPS outage stops
unattended upgrades fleet-wide, including on the host that actually serves
forgejo.
Testing, and deploying without waiting
nixos-rebuild test/switch --flake .#<host> still builds the working tree on
demand, so day-to-day testing is unchanged. The catch: a machine left on a test
build silently reverts to main at the next morning auto-upgrade, so re-apply
the working tree after waking a machine you were mid-test on.
To push an urgent change to another host immediately:
nixos-rebuild switch --flake .#<host> --target-host <host> --build-host <host> --sudo
--build-host runs nix flake archive --to ssh://<host> under the hood, which
ships the flake and its resolved inputs — so the target needs no checkout,
and no deploy-key access of its own, for this path. --sudo authenticates
through pam_rssh against the ssh agent, so it doesn't prompt. main stays the
unattended path: anything deployed this way is overwritten with the morning
auto-upgrade unless it is also committed and pushed.
Changing secrets
nixos-secrets must be pushed, not just committed, before
nix flake update my-secrets will see the change.
Binary cache
homeserver serves its own /nix/store to the other hosts, so a nightly
upgrade mostly comes off the LAN instead of cache.nixos.org.
It is served over ssh, not http (nix.sshServe in
hosts/homeserver/services/nix-cache.nix). nix.sshServe creates an
unprivileged nix-ssh account whose only capability is
ForceCommand nix-store --serve, read-only, with forwarding and TTY off.
Authorization is the per-host key already in ssh-keys.nix — the clients are
derived from nixosConfigurations ∩ hostKeys, so a new host is authorized
once it exists in flake.nix and its host key is recorded, with no list to
maintain (install media has no host key).
Three things are easy to get wrong:
-
Clients reject unsigned paths, whatever the transport. Locally-built paths carry no signature, so
homeserversigns at build time vianix.settings.secret-key-files(sopsnix-cache-key); clients trust the public half throughextra-trusted-public-keys.secret-key-filesonly signs paths built after it is enabled — sign what is already there once:sudo nix store sign --key-file /run/secrets/nix-cache-key --all
priority=10 puts it ahead of cache.nixos.org (40), so anything homeserver
has is preferred. the garbage-collection policy is the retention policy. If
a path is deleted from the store it leaves the cache, so the cache holds the
most recent builds but not much history.
Local packages
pkgs/ contains derivations for small one-off apps maintained alongside this
repo. Each lives in pkgs/<name>/ with its own default.nix, and is wired up
through pkgs/default.nix ({ pkgs }: { ... = pkgs.callPackage ./<name> {}; }).
Service modules consume them with
localPkgs = import ../../../pkgs { inherit pkgs; }.
Local Package list
today— minimal Flask webapp for quick diary, workout, and book entries into the wiki. Deployed onhomeserverviahosts/homeserver/services/today.nixattoday.lan.firecat53.net.dashboard- static home service bookmark page served athome.lan.firecat53.net
Adding / removing services and hosts
Domain naming conventions
| name | served by | reachable from | what it's for |
|---|---|---|---|
*.lan.firecat53.net |
homeserver Traefik | LAN + wireguard only | the "home" name every homeserver web app gets (real LE certs, but the name resolves to a LAN address) |
*.firecat53.me |
VPS Traefik | internet | public front door: proxies over wireguard to the homeserver .lan name, or to a VPS-local port |
*.firecat53.com |
VPS Traefik | internet | VPS-native public apps (grafana, VPS nextcloud, syncthing, Authelia portal, apex website) |
firecat53.net + subs (matrix., s., nc., …) |
homeserver Traefik directly (443 port-forward) | internet | federated/public apps that must be reached directly (Matrix, Akkoma, Nextcloud) — hand-written, not in the registry |
Rules of thumb:
- At home or on wireguard → use the
.lanname. Direct to the homeserver, no VPS hop; basicAuth prompt if the service has one. - Out in the world → use the
.mename. The VPS terminates TLS, Authelia 2FA's you (ifauth = true), then proxies over wireguard to the same homeserver backend. .lanand.meare two doors to one app — the entry inhosts/modules/service-registry.nixis what ties them together.- Service on the VPS itself →
.meif it's registry-managed (behind Authelia),.comif it's hand-written and fully public. - Not web / not HTTP isn't covered by any of this: ssh, syncthing sync, and
wireguard are direct port openings on their hosts; Forgejo ssh
(
git.firecat53.me:2222) is a TCP passthrough on the VPS Traefik (proxy-me.nix) to the homeserver.
Web services (*.lan.firecat53.net + *.firecat53.me)
Standard web services on both hosts are driven by a single shared registry,
hosts/modules/service-registry.nix. Derived from it:
| consumer | generates |
|---|---|
homeserver services/lan-proxy.nix |
the .lan Traefik routers/services (+ -me/-noauth companions) |
vps services/proxy-me.nix |
the *.firecat53.me reverse-proxy routers/services |
vps services/authelia.nix |
2FA-protected domains (auth = true) + access_control rules |
vps services/gatus.nix |
homeserver .lan backend resolution for monitoring |
homeserver services/dashboard.nix |
build-time assertion that dashboard tiles point at real services |
Each entry's fields — the attr name is the *.firecat53.me subdomain, and
every flag controls exactly one generated thing (nothing is derived from
another flag):
| field | where | meaning |
|---|---|---|
lan |
homeserver sets | the .lan router host, and the backend the VPS proxies to over wireguard |
port |
all | backend port on localhost |
url |
all | backend URL; overrides port for non-localhost backends (e.g. hass VM) |
auth |
homeserver/vps | true gates <sub>.firecat53.me behind Authelia 2FA |
passHost |
homeserver | VPS forwards the real *.firecat53.me Host header (see below) |
meRouter |
homeserver | homeserver companion router on <sub>.firecat53.me (see below) |
basicAuth |
homeserver | homeserver router gets the auth basicAuth middleware |
vpsBypass |
homeserver | homeserver -noauth companion router skipping basicAuth for VPS traffic |
rules |
homeserver/vps | optional; per-service Authelia access_control rules (see below) |
Service running on homeserver:
- Create
hosts/homeserver/services/<name>.nixdefining just the app (bound to a localhost port), and add it tohosts/homeserver/services/default.nix. No Traefik config. - Add one entry to the
homeserverset in the registry (orlanOnlyfor services that should not be exposed at*.firecat53.me):<sub> = { lan = "<sub>.lan.firecat53.net"; port = <port>; auth = <true|false>; }; - Rebuild
homeserver, thenvps. The.lanrouter, the<sub>.firecat53.merouter, the Authelia rule (ifauth = true), and Gatus resolution appear automatically.
To inspect the fully-rendered result of the generated config:
nix eval --json .#nixosConfigurations.<host>.config.services.traefik.dynamicConfigOptions | jq
Services with their own HTTP basic auth (basicAuth = true — e.g. gollum,
today, syncthing, transmission): the auth model is basicAuth on the LAN,
Authelia on the internet — not both. The VPS proxies *.firecat53.me traffic
in from 10.200.200.5, so set vpsBypass = true to generate a companion
router that matches that source IP and omits the auth middleware, letting
Authelia-2FA'd requests (and Gatus probes) through without a second prompt.
LAN/wireguard clients keep hitting the plain Host() router and still get
basicAuth. This relies on the homeserver Traefik not trusting forwarded
headers (so ClientIP is the real TCP source). Services without basicAuth
(app-level login, or auth = false) need no companion router.
Apps that build absolute redirects/URLs from the Host header (e.g. gollum,
sonarr, OAuth redirect flows): the ClientIP trick won't work, because the
backend would see the .lan host and redirect clients there (broken off-LAN).
Instead set passHost = true so the VPS forwards the real
<sub>.firecat53.me host, plus meRouter = true to generate the homeserver
companion router keyed on that host (no ClientIP needed — only the VPS ever
sends that host, already 2FA'd, so it carries no basicAuth).
Service running locally on the VPS:
- Create
hosts/vps/services/<name>.nix(+ add it toservices/default.nix), binding the app to a localhost port. - Add one entry to the
vpsset in the registry:<sub> = { port = <port>; auth = <true|false>; }; - Rebuild
vps.
Removing a service: delete its registry entry (and the service file + its
default.nix import). The .lan router, proxy router, auth rule, and monitor
resolution all disappear with it — and the dashboard assertion fails at build
time if a tile still points at it. Remove OIDC from authelia.nix if necessary.
Oddballs stay hand-written in their own service files (path-prefix rules,
extra middlewares, non-.lan hosts): matrix-synapse, akkoma, nextcloud, and
the nginx lan.firecat53.net apex. If a service outgrows the registry schema,
move it to a hand-written stanza rather than adding fields.
Authelia: access control and OIDC
Access Control rules: auth = true entries get a blanket two_factor rule.
To override that for specific paths (e.g. keep some public), add a rules list
to the entry — Authelia access_control rules evaluated before the blanket
rule, first match wins. Omit each rule's domain; authelia.nix derives it
from the attr name. For example, Microbin uses this to keep paste viewing public
but require authentication for submitting/delting pastes.
OIDC (apps that log in through Authelia rather than forward-auth, e.g.
immich, audiobookshelf): the client is defined in
identity_providers.oidc.clients in authelia.nix (hashed client_secret +
redirect_uris), and the app stores the plaintext secret in its own sops
secret. This pair is cross-host (Authelia on the VPS, app on the homeserver), so
it is not registry-derived. See the secret-generation commands at the top of
authelia.nix.
Hosts
To add a host:
- Add it to
flake.nix(mkSystem). - Set its wireguard address in the host's
configuration.nix(networks."wg0".address). - Add its wireguard/LAN IPs to
networking.hostsinhosts/modules/desktops/networking.nix. - Add sops keys (see the install/post-install sections below).
- Install per General Install Procedures.
Monitoring
In general, VPS services are monitored from the Gatus instance on homeserver and homeserver LAN services are monitored from Gatus on VPS via a wireguard tunnel. Prometheus scrapes metrics from all the servers and feeds Alertmanager and Grafana.
Podman healthchecks
A HostSystemdServiceCrashed alert for a unit like
e2d6c5…c3d86-3c8e64…0a02.service is not a real service. Podman runs each
container healthcheck in a transient unit named after the container ID, and a
failed probe leaves it failed until the next tick.
Because the prometheus systemd collector is enabled, this generates a lot of
recurring noise in prometheus due to these checks. Service monitoring (Gatus) is
used instead of the container health checks for issue notifications. The
container health checks are filtered via a regex in prometheus-exporters.nix.
Note that flag overrides node-exporter's default exclusions rather than
extending them, hence the defaults repeated there.To make podman health checks
actionable, set --health-on-failure=restart, or export .State.Health.Status
as a textfile metric (pattern: airvpn-port-check.nix). The container's own
podman-<name>.service is unaffected and still alerts if a container dies.
What the qBittorrent VPN checks actually prove
VPS gatus checks the QBT GUI and socks-proxy, but both are port-mapped to the
host (-p 127.0.0.1:8081:8081, -p 2222:22 on the pod), so they answer even
if the tunnel is down — they prove the containers are alive, not that traffic
is egressing. Tunnel egress is covered by vps services/airvpn-port-check.nix,
which probes the AirVPN forwarded port from outside. It can take 30-45 minutes
for the system to notice that the AirVPN tunnel is actually down.
General Install Procedures
Tips
- Generate hostId (for ZFS systems):
head -c4 /dev/urandom | od -A none -t x4 - Hetzner VMs apparently require grub instead of systemd-boot (as of 2025-08)
- Available options (defined in
hosts/modules/common/options.nix): a. isRemote (bool) - set for hosts directly exposed to the internet (not behind the LAN firewall). Controls the wireguard endpoint and keeps LAN-only ports (e.g. Eternal Terminal 2022) closed publicly. Default false. b. isVirtual (bool) - set for virtual hardware (VPS or VM). Disables hardware-specific services (fwupd, smartd). Default false. c. latestZFSKernel (bool) - set to use latest available ZFS compatible kernel. Default false. d. tmuxStatusColor (str) - tmux status bar background color, for per-host visual distinction. Default "#cba6f7" (catppuccin mocha mauve).
Installing using nixos-anywhere
https://github.com/nix-community/nixos-anywhere/blob/main/docs/quickstart.md
- Create new (Ubuntu is fine) cloud server. Add one of the public keys. Adjust DNS 'A' records if needed.
- SSH into the new box and update the disk device name(s) and partition layout (if needed) in disko-config.nix.
nix run github:nix-community/nixos-anywhere -- --generate-hardware-config nixos-generate-config ./hosts/<host>/hardware-configuration.nix --flake .#<host> --target-host root@<ip or domain>- If problems arise, add
--no-rebootto the above command so you can troubleshoot the new install. - Post install — sshd generates the host key on first boot, so the rekey there is required before the config will build cleanly.
Installing locally on a new machine using the ISO installer
Two stages because a full host closure will not reliably build from a booted ISO — it has run the installer out of memory more than once, even at 8GB+.
Stage 1 installs #minimal (hosts/installer/minimal): small enough to build
without running out of memory, with no sops or home-manager. Install also
generates a new SSH host key.
Stage 2 re-keys the secrets to use the new host key and then builds the actual machine config.
# 1. Boot the ISO. Device keys from ssh-keys.nix are already authorized for
# the `nixos` user, so just ssh in.
sudo mkdir -p /run/data && sudo mount /dev/disk/by-label/DATA /run/data
cp -a /run/data/{nixos,shared,dotfiles} ~/
NIXOS=~/nixos/nixos
# 2. Partition (update device ids first).
sudo disko --mode disko $NIXOS/hosts/<host>/disko-config.nix
# 3. Hardware scan into the minimal target. Drop --no-filesystems if not disko.
sudo nixos-generate-config --no-filesystems --show-hardware-config --root /mnt \
> $NIXOS/hosts/installer/minimal/hardware-configuration.nix
# 4. Stage 1. --no-root-password: users.nix already sets initialHashedPassword.
# If the build runs out of space: sudo mount -o remount,size=8G /run/user/0
sudo nixos-install --flake $NIXOS#minimal --no-root-password
sudo cp -a ~/{nixos,shared,dotfiles} /mnt/home/firecat53/
sudo chown -R 1000:1000 /mnt/home/firecat53/{nixos,shared,dotfiles}
sudo umount -R /mnt && sudo zpool export -a && sudo systemctl reboot
Then post install.
Post install
- Change the
firecat53and root passwords. - Sync ~/nixos/ to the new machine (configs and secrets) if needed.
- Generate SSH keys per SSH key generation. Needed to push to forgejo in step 4.
- Rekey. Also update
hostKeys.<host>inssh-keys.nixand re-add the pubkey as a read-only deploy key on the forgejonixos-secretsrepo — it is themy-secretsdeploy key too. Being read-only, it cannot push: that needs the device key from step 3, or do the push from a working host.
# Add AGE-SECRET-KEY-1... from the pw vault for any machine using home-manager
mkdir -p ~/.config/sops/age
(umask 077; $EDITOR ~/.config/sops/age/keys.txt)
export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt
# deriving the recipient - on the new host itself:
ssh-to-age -i /etc/ssh/ssh_host_ed25519_key.pub
# ... or remotely, for a host with no age key of its own:
ssh-keyscan <hostname> | ssh-to-age
# then update the sops config with the new age recipient
cd ~/nixos/nixos-secrets
$EDITOR .sops.yaml # &<host> age1...
sops updatekeys <host>/secrets.yaml common/secrets.yaml
git commit -am 'Rekey <host>' && git push
- Build the real config.
--override-inputcovers the window before the rekey reaches forgejo; afterwards refresh the lock and commit it.
cd ~/nixos/nixos
nixos-rebuild switch --flake .#<host> \
--override-input my-secrets path:$HOME/nixos/nixos-secrets --sudo
nix flake update my-secrets && git commit -am 'Rekey <host>' && git push
Until that lock change is on main, the daily auto-upgrade rebuilds from a
.sops.yaml with the old host key and activation fails.
If the rekey was done remotely there is no local checkout to override
against: push it first, then bump the lock and
deploy from the working host.
6. sudo nmcli connection import type wireguard file /etc/wireguard/wg0.conf
for networkmanager.
7. Update syncthing device ID's if necessary. Re-add servers on phones and
wife's laptop if needed.
SSH key generation (new or rebuilt host)
The device key (devices.* in hosts/modules/common/ssh-keys.nix) — not
the host key, which sshd generates at stage-1 boot and the install procedure
rekeys into hostKeys.* of that same file.
Each desktop/laptop host gets its own SSH keypair — private halves never leave
the box, only pubkeys land in ssh-keys.nix. Sequence matters because the host
can't reach itself via key auth until its pubkey is authorized elsewhere.
- On the new/rebuilt host, generate the device key as
firecat53:
ssh-keygen -t ed25519 -C "firecat53@<hostname>" -f ~/.ssh/id_ed25519
wl-copy < ~/.ssh/id_ed25519.pub
- On a working host with repo access, paste the pubkey into
hosts/modules/common/ssh-keys.nixunder the matchingdevices.<host>attribute. Commit and push. - Add the device pubkey to:
a. GitHub / forgejo account (web UI) — needed for git operations
b. HomeAssistant
~/.ssh/authorized_keysfor therootuser c. Any other external service the host needs to reach - Rebuild every host that should authorize this device:
- (Desktops/laptops using the autossh tunnel only) The passphraseless
autossh private key is shared across all tunnel clients and lives in
sops as
autossh-key. Add it to the host's sops file (the matching pubkey is already inssh-keys.nixasautossh, authorized on homeserver). To rotate, generate one new keypair, updateautosshinssh-keys.nix, and re-encryptautossh-keyinto every desktop sops file.
Specific host instructions
Minimal and Base Installs
- Copy/rename desired exmaple directory to hosts/xxxxx.
- Update CHANGEME items (disk device id, disk encryption, etc).
- Update configuration as desired.
a. If using base-btrfs with encryption, rename
disko-config-luks.nixtodisko-config.nix - Add new host to flake.nix.
- Sops-nix (if needed):
a. Add any sops-nix keys to nixos-secrets/xxxx/secrets.yml
b. Add new host to nixos-secrets/.sops.yml
c.
sops updatekeyshappens after install d. Update flake inputs - Install using nixos-anywhere
BACKUP server
- Install
ssh-keygen -f /etc/ssh/backup && chown backup: /etc/ssh/backup. ChangebackupPullto the public key inssh-keys.nixand rebuild all servers.sudo -i -u backup ssh -i /etc/ssh/backup <backup source hostname(s)>and accept fingerprint
LAPTOP/OFFICE desktops
- Install
- Login to Vaultwarden
- Login to Firefox Sync a. Extensions - ClearURLs, floccus, Gnome Shell integration, Proxy SwitchyOmega 3, Stylus, uBlock Origin, User-Agent switcher and Manager, Vimium
- Open Syncthing on this machine and other machines. Ensure syncing is setup.
- Stow (dotfiles)
cd home/firecat53/docs/family/scott/src/dotfiles
stow -t /home/firecat53/ --dotfiles stow/
stow gomuks music passwords python ssh-scotty
Homeserver
Disko (WARNING: instructions not completely verified working yet)
This directory contains disko configuration for homeserver's two-NVMe-drive mirrored ZFS setup with systemd-boot.
Current Layout
Both NVMe drives have identical partition layouts:
| Part | Size | Purpose |
|---|---|---|
| p1 | 1G | EF00 ESP (vfat) - /boot on nvme0 |
| p2 | 4G | Unused (legacy bpool placeholder) |
| p3 | ~1.8T | rpool (ZFS mirror) |
| p4 | 8G | Encrypted swap |
- rpool: Mirrored across both NVMe drives
- ESP: Only the first drive's ESP is mounted at
/boot(systemd-boot) - datapool: Separate SATA drives (not managed by disko)
Safety Information
WARNING: Do NOT run disko --mode disko on an existing system. It would
reformat the drives. The disko config is used only as a NixOS module for
fileSystems generation, and as a reference for future fresh installs.
Scenario A: Fresh Install (Empty Drives)
-
Boot into NixOS installer
-
Clone your configuration
git clone <your-repo-url> /tmp/nixos-config
cd /tmp/nixos-config
- Review and adjust disko-config.nix
Check these settings in hosts/homeserver/disko-config.nix:
- Disk devices: Update device paths to match your drives
- Partition sizes: Adjust if needed (swap=8G, rpool uses remaining space)
- Pool/dataset options: Modify compression, reservation, etc. as desired
- Run disko to partition and format
sudo nix run github:nix-community/disko -- --mode disko /tmp/nixos-config/hosts/homeserver/disko-config.nix
This will:
- Partition both NVMe drives
- Create the mirrored rpool ZFS pool and all datasets
- Format the ESP partition
- Set up encrypted swap on both drives
Note: This does NOT touch the SATA drives (datapool). Import datapool separately after install.
- Install NixOS
Disko automatically mounts everything to /mnt.
sudo nixos-install --flake /tmp/nixos-config#homeserver
- Reboot
reboot
- Post-install
sudo zpool import -f datapool
Scenario B: Fresh Install + Migrate Data via zfs send/recv
1-4. Follow Scenario A steps 1-4
Run disko to partition, create pools, and mount everything. This creates empty datasets.
- Receive ZFS data into the new pools
Before installing NixOS, populate the datasets with your data:
# Import the old/backup pool with an alternate name
sudo zpool import -R /tmp/oldpool oldrpool
# Recursive send of all data datasets
sudo zfs snapshot -r oldrpool/data@migrate
sudo zfs destroy -r rpool/data
sudo zfs send -R oldrpool/data@migrate | sudo zfs recv rpool/data
# Fix mountpoints to use legacy (disko expects legacy mounts)
sudo zfs set mountpoint=legacy rpool/data/home
sudo zfs set mountpoint=legacy rpool/data/podman_volumes
# ... etc for each dataset
Note: You generally don't need to migrate system datasets (rpool/nixos/*)
since NixOS will rebuild those during install. Focus on the rpool/data/*
datasets. datapool lives on separate SATA drives - just import it directly.
After migrating, re-mount everything:
sudo umount -R /mnt
sudo nix run github:nix-community/disko -- --mode mount /tmp/nixos-config/hosts/homeserver/disko-config.nix
- Clean up and install
sudo zpool export oldrpool
sudo nixos-install --flake /tmp/nixos-config#homeserver
reboot
Recovery from failed drive (e.g. nvme0 failed)
- Boot: enter the firmware boot menu and select the second NVMe drive. It boots via EFI/BOOT/BOOTX64.EFI (the removable-media fallback).
- Replace the dead drive in the ZFS mirror:
sudo zpool replace rpool <old-nvme0-part3> /dev/disk/by-id/<new>-part3
- Recreate the ESP on the replacement drive and let the next rebuild resync:
sudo mkfs.vfat -F32 -n ESP /dev/disk/by-id/<new>-part1sudo nixos-rebuild boot --flake .#homeserver# repopulates /boot
VPS (cloud server)
homeserver runs mbsync with mail stored in ~/mail. Syncthing replicates it
to/from the desktops with Neomutt working from that local maildir. Only sending
goes out directly via SMTP.
Notmuch
The xapian database lives at ~/mail/.notmuch and is excluded from syncthing by
~/mail/.stignore, so each desktop keeps its own tags. Only the maildir
itself is shared between laptop and office.
One folder tag per maildir folder is rebuilt from the maildir on every run by
the post-new hook, so mail moved in one client retags itself on the next
index. unread and flagged come from the maildir flags directly. The folder
list lives in home-manager/common/mail-folders.nix and also drives neomutt's
sidebar and the indexing path unit, so the three can't drift apart.
Mail arrives by syncthing rather than a local delivery agent, so nothing
triggers indexing on its own. notmuch-new.path watches every folder's cur
and new for inotify events, and since inotify only reports live changes, the
notmuch-new timer sweeps hourly to catch mail that landed while the session
was down. On a new desktop run notmuch new once by hand first.