sov: design #
sov turns one typed specification (an instance repository) into a running server and keeps it that way. This file is the design; the code in this repository is the implementation.
Goal #
From "a VPS was just ordered" to "everything works" with one command, every time, with no step that only exists in someone's memory.
- Configuration (OS, services, sites) comes from the instance repository.
- State (git repositories, statistics, server-only secrets) comes from the source bucket and is restored automatically on first boot.
- Secrets that are typed in or shared with outside services live encrypted in the instance repository; the owner's key unlocks them.
Three places #
| Place | Holds |
|---|---|
| The instance repository | the specification (spec/), the sites, the shared secrets, the machine (host/) |
| sov (this repository) | how everything is derived from a specification |
| The server's disk | the running system and the state |
| The source bucket (Hetzner Storage Box) | the system of record for the state: encrypted restic snapshots every 5 minutes, box snapshots on top |
Nothing else holds anything that matters. Certificates, DNS zones, healthchecks.io checks and the GitHub mirror are derived: the server recreates them from the spec.
Layers #
| Layer | Tool | Lives in |
|---|---|---|
| Server OS, services, firewall, users | NixOS 26.05 (flake, pinned by flake.lock) |
modules/, instance host/ |
| Disk layout / first install | disko / nixos-anywhere | instance host/disk.nix, tools/install.sh |
| Updates | comin: pulls main (knot, GitHub mirror as fallback), accepts only commits signed by the owner's key |
modules/comin.nix |
| HTTPS | Caddy, automatic certificates (Let's Encrypt, ZeroSSL fallback) | modules/caddy.nix, modules/sites.nix |
| DNS | Knot as hidden primary; Hetzner's three nameservers as secondaries (signed transfers and NOTIFY); CAA limits certificate issuers | modules/dns.nix |
| Git hosting | Tangled knot; private repos mirrored to GitHub | modules/knot.nix, modules/mirror.nix |
| Statistics | GoatCounter, fed from Caddy's access logs (no script on the pages) | modules/stats.nix |
| Backups | restic → Storage Box (5 min), daily prune + partial read check + test restore; restore on fresh install | modules/backup.nix |
| Server-only secrets | created once on the server, kept as state | modules/secrets.nix |
| Shared secrets | sops-nix (owner's SSH key and the server's host key as recipients) | instance secrets/, .sops.yaml |
| Alarms | healthchecks.io (synced from spec.heartbeats), email + ntfy push |
modules/heartbeats.nix |
| Recovery drill | monthly: the server installs a copy of itself on a temporary Hetzner VM (nixos-anywhere + host key + restore from the bucket), compares it with itself, deletes the VM | modules/drill.nix, modules/drill-target.nix |
| Live checks | invariants, hourly: sites, certificates, redirects, DNS serials and delegation, knot, stats, mirror, deployed commit, disk |
modules/checks.nix |
The instance repository #
spec/default.nix the specification, checked against spec/schema.nix
sites/<name>/ each website's source with a package.nix (may use sov.staticSite, sov.fonts)
secrets/server.yaml secrets the server reads (sops)
secrets/admin.yaml secrets only the owner's tools read
secrets/host_key.sops the server's SSH host key, its permanent identity; host_key.pub
host/default.nix the machine: hardware, disk, network
.sops.yaml who can decrypt what
flake.nix outputs = { self, sov }: sov.lib.instance { inherit self; };
Every module declares the folders it keeps (sov.state); together they are what the source bucket
holds and what a fresh install restores. Users owning such folders have fixed ids, because restic
stores owners as numbers.
Domains #
Registrar: INWX, nameservers from spec.dns.nameservers. The parent zones' delegation is checked
by invariants; changing it is one of the steps in operations.md.
Verification #
nix flake check: evaluates the spec, builds everything, boots the base config in a VM test.- On the server:
invariants(hourly, and on demand withsystemctl start invariants). - Recovery: daily, a restore into a scratch directory with database and repository checks; monthly,
a full recovery drill onto a temporary VM (
systemctl start recovery-drillruns one now).