NixOS homelab — two hosts managed with deploy-rs and flake-parts.
| Host | Role | Location |
|---|---|---|
sorbet |
Main homelab server | LAN 192.168.0.85 |
eclair |
Public-facing VPS | Tailnet + public IP |
Internet
│
▼
eclair (VPS)
haproxy :443 — TCP SNI passthrough
CrowdSec — nftables IP banning
│ tailnet
▼
sorbet (LAN)
caddy — TLS termination (ACME via Bunny DNS)
services — Navidrome, Home Assistant, etc.
*.ext.kuipr.de DNS records point to eclair's public IP. haproxy inspects the TLS SNI header, matches against all *.ext.kuipr.de virtual hosts declared in the flake, and TCP-proxies the connection to sorbet over Tailscale. Caddy on sorbet terminates TLS end-to-end — eclair never sees plaintext.
*.int.kuipr.de resolves to 192.168.0.85 via dnsmasq wildcard — LAN only, not routed through eclair.
hosts/<host>/ per-machine configuration (sorbet, eclair)
modules/flake/ flake-parts plumbing: deploy nodes, systems, checks
services/ third-party services you configure
pkgs/ third-party software you package
apps/ (planned) source for daemons you write, colocated
with their _package.nix and _module.nix
secrets/ sops-encrypted, kept central on purpose
docs/ (planned) runbooks and decision records
All .nix files under hosts/, modules/ and services/ are auto-imported
by import-tree as flake-parts modules. Any path containing /_ is skipped —
that is how a directory keeps helper files next to its module without them
being evaluated as flake-parts modules.
pkgs/ is deliberately outside the import-tree roots: its files are a
derivation and a NixOS module, imported by hand from services/unifi.nix.
Service modules contribute to the flake via:
flake.nixosModules.*— applied to sorbetflake.eclairNixosModules.*— applied to eclairflake.caddyVirtualHosts— Caddy site blocks, also read by haproxy to auto-generate SNI ACLs
Adding a new public service: declare flake.caddyVirtualHosts."myservice.ext.kuipr.de" in the service module — haproxy picks it up automatically on next deploy.
Secrets are encrypted with sops-nix using age keys. Files live under secrets/<host>/<service>.
| Secret file | Used by |
|---|---|
secrets/sorbet/caddy |
Caddy — BUNNY_API_KEY for ACME DNS challenge |
secrets/sorbet/tailscale |
sorbet tailscale auth key |
secrets/sorbet/homepage |
Homepage dashboard env vars |
secrets/sorbet/gatus |
Gatus — Discord webhook |
secrets/sorbet/beszel-agent |
Beszel agents — hub public key + WS token |
secrets/sorbet/beszel-hub-key |
Beszel hub — ed25519 signing key |
secrets/sorbet/github-runner |
GitHub Actions runner token |
secrets/sorbet/rclone |
rclone Google Drive config |
secrets/sorbet/beets |
beets config |
secrets/eclair/tailscale |
eclair tailscale auth key |
secrets/eclair/beszel-agent |
Beszel agents — hub public key + WS token |
secrets/sorbet/homelab-bot.env |
homelab-bot — Discord token, app ID, guild ID |
secrets/shared/deploy-webhook |
Discord deploy notifications |
Both hosts share a single age key listed in .sops.yaml.
Both hosts use the same age private key stored at /var/lib/sops/age-key.txt. This file must exist before NixOS activation — sops-nix will fail to decrypt secrets without it.
The key is stored in 1Password. Upload it to either host with:
op read "op://Personal/sorbet.nix/Private Key" \
| ssh root@sorbet "mkdir -p /var/lib/sops && cat > /var/lib/sops/age-key.txt && chmod 400 /var/lib/sops/age-key.txt"
op read "op://Personal/sorbet.nix/Private Key" \
| ssh root@eclair "mkdir -p /var/lib/sops && cat > /var/lib/sops/age-key.txt && chmod 400 /var/lib/sops/age-key.txt"just deploy sorbetjust deploy eclairThe deploy recipe calls scripts/deploy.sh, which uses deploy-rs for remote hosts. remoteBuild = false for eclair — the closure is built locally and pushed to the VPS.
just install root@<target-ip>Installs NixOS from scratch via nixos-anywhere. The config variable at the top of the justfile controls which flake target is installed.
just buildRuns nix fmt + nix flake check + builds the sorbet toplevel locally (requires a Linux builder on macOS).
CrowdSec runs on eclair as agent + nftables firewall bouncer. The agent watches haproxy and sshd journals for attack signals and feeds decisions to the bouncer, which DROPs banned IPs via nftables before they reach haproxy.
ssh root@eclair cscli decisions listssh root@eclair cscli alerts listssh root@eclair cscli hub update && cscli hub upgradessh root@eclair cscli decisions delete --ip <ip>- In the service's
.nixfile, add aflake.caddyVirtualHosts."myservice.ext.kuipr.de"entry. - Point
myservice.ext.kuipr.deDNS A record to eclair's public IP. - Deploy sorbet (caddy picks up new vhost) then eclair (haproxy regenerates SNI ACLs).
No other changes needed — SNI routing is derived automatically from caddyVirtualHosts.
nix fmt . # formats every language in the tree
nix flake check # treefmt + deadnix + statixnix fmt is treefmt, configured once in modules/flake/treefmt.nix. It
currently drives alejandra (Nix) and shfmt (shell); adding Go, Rust or
prettier is one line there and nothing anywhere else. Formatting is also a
flake check, so CI fails on an unformatted tree.
deadnix and statix stay separate checks on purpose — they inspect, they do not rewrite.
just update-inputsjust beet import ~/Downloads/album
just beet <any beet command>