This repository has no description
README.md

homelab #

NixOS configurations for two machines, as a flake at the root of this repo.

Host Runs Managed by
marge Tangled knot (git hosting), spindle (CI) this flake
homer FreshRSS, linkding, the blog, Cloudflare tunnel, spindle this flake
bart workstation both are deployed from neither

nixpkgs has a services.knot, but that is Knot DNS — unrelated. Do not wire it up by name-matching.

Deploying #

nix --extra-experimental-features "nix-command flakes" run nixpkgs#nixos-rebuild -- switch --flake .#marge --build-host marge --target-host marge --sudo
nix --extra-experimental-features "nix-command flakes" run nixpkgs#nixos-rebuild -- switch --flake .#homer --build-host homer --target-host homer --sudo

nixos-rebuild is not installed on bart, hence nix run. nix-command and flakes are stable on NixOS (Fedora's nix package enabled them by default) but ship disabled on other distros' packages, including Arch's — hence the flag. Set experimental-features = nix-command flakes in ~/.config/nix/ nix.conf to drop it from every invocation instead. Roll back with ssh <host> sudo nixos-rebuild --rollback switch, or pick an earlier generation from the boot menu.

Secrets: nix --extra-experimental-features "nix-command flakes" develop (provides sops, age, ssh-to-age), then sops secrets/homer.yaml.

Restoring backups #

modules/services/backup.nix sends restic backups of each host's stateful services — the knot's repos on marge; FreshRSS, linkding, and PDS data on homer — to that host's own B2 bucket, once daily.

If the host is alive, its B2 credentials are already on disk, rendered by sops:

ssh <host>
sudo -s
set -a; source /run/secrets-rendered/restic.env; set +a
nix shell nixpkgs#restic -c restic -r b2:gregbair-homelab-<host>:restic snapshots
nix shell nixpkgs#restic -c restic -r b2:gregbair-homelab-<host>:restic restore latest --target /tmp/restore

Restore to a scratch directory and copy the bit you need back into place rather than restoring straight over a live service's data directory — stop the service first if you do restore in place, since restic preserves the original owner/permissions but won't stop anything reading the directory mid-restore.

If the host is gone, its B2 credentials died with it — but secrets/ <host>.yaml is encrypted to every recipient in .sops.yaml, not just that host, so decrypt it with your own key instead:

nix develop   # sets SOPS_AGE_KEY from your ed25519 key
sops -d secrets/<host>.yaml   # B2_ACCOUNT_ID, B2_ACCOUNT_KEY, RESTIC_PASSWORD

Export those three, and restic will talk to b2:gregbair-homelab-<host>: restic from any machine with network access — the host that made the backup does not need to still exist to read it back.

Layout #

.
├── flake.nix                 # inputs + four nixosConfigurations
├── hosts/
│   └── <host>/
│       ├── default.nix       # the full config
│       ├── bootstrap.nix     # install-time; no sops, no services
│       ├── hardware.nix      # from nixos-generate-config
│       └── boot.nix          # bootloader, kept apart from hardware
├── modules/
│   ├── common.nix            # ssh, tailscale, users, nix, firewall
│   ├── sops.nix              # imported only by hosts with secrets
│   └── services/             # spindle.nix on both; the rest per-host
├── secrets/                  # one sops file per host
└── INSTALL-<host>.md         # runbooks, as executed

Things that look odd, and why #

Each is explained where it lives; this is the index.

Two configurations per host. Secrets are encrypted to the host's SSH key, which does not exist until the install creates it — so a first install runs .#<host>-bootstrap, which omits sops and every service.

Bootloader lives outside <host>-hardware.nix. A reinstall overwrites that file with generator output, which never contains bootloader settings. Putting it there produced an unbootable marge once.

sops is a per-host import. A host with no secrets carries nothing; marge is such a host.

Secrets never appear as literals. Anything in a .nix lands in the world-readable store, so values arrive through sops.templates + environmentFile, or LoadCredential. The knot's hostname and owner DID are plain options — both are public by nature.

Published container ports ignore the firewall. Runtimes DNAT them in PREROUTING, so they traverse FORWARD, not INPUT, and nixos-fw never sees them. The bind address is the enforcement — hence the blog on 127.0.0.1:8081.

The blog runs on Docker. The spindle's module enables Docker unconditionally, and two runtimes for one static site was not worth it. Its update timer exists because Docker has no AutoUpdate=registry.

The spindles listen differently. homer's on loopback, since cloudflared is on that host; marge's on 0.0.0.0:6555, since it is dialled across the LAN.

Public hostnames are flat — spindle-marge, not marge.spindle. Cloudflare's free Universal SSL covers one level of subdomain, so anything deeper fails the TLS handshake while DNS looks perfect.

The tunnel was recreated from the CLI. A dashboard-created tunnel is remotely managed: Cloudflare pushes stored ingress that overrides --config.

Port 5555 is opened by hand. openFirewall opens 22 only, but the knot serves its API on 5555 — without it the knot runs and is invisible to Tangled.

Git SSH shares the host's sshd on 22, scoped to the git user via AuthorizedKeysCommand. Remotes lose the port: ssh://git@marge/<repo-did>.

trusted-users includes @wheel. Paths built on bart are unsigned, so --target-host fails without it — and it cannot be delivered by the deploy it enables.