// OpenBao server config for the Thermograph estate. Lives at /etc/openbao/config.hcl // on vps2. Verified against OpenBao v2.6.1 (2026-07-22). // // ============================================================================ // WHY THIS RUNS AS A NATIVE SYSTEMD SERVICE AND NOT A CONTAINER // ============================================================================ // The estate is Docker-native and this is the one thing that must not be. The rule // is that OpenBao must not depend on anything it secures. // // * A Swarm service is fatal: deploy-stack.sh renders secrets in order to deploy // the stack, so the stack cannot contain the thing that holds them. // * A standalone `docker run --restart=always` is merely bad: it makes the vault's // availability a function of the same Docker daemon that deploys restart, and // leaves it one careless `docker system prune -a` from gone. The root CLAUDE.md // already lists `prune -a` as a thing that eats the rollback image; it would // eat this too. // // A native binary with Restart=always has exactly one dependency: the local disk. // See infra/openbao/openbao.service. // // Corollary that is easy to get wrong: the listener cert must NOT come from the // Caddy/ACME path. That would put DNS and the public internet in the boot chain of // the estate's secret store. It is self-signed, long-lived, and on disk. // ============================================================================ ui = false cluster_name = "thermograph" log_level = "info" // `disable_mlock` is deliberately absent: OpenBao 2.6.1 does not recognise it and // logs `unknown or unsupported field disable_mlock` on every start. Carrying a // setting the server ignores only trains the operator to skim startup warnings, // which is where a real one will eventually hide. The corresponding note about // AmbientCapabilities=CAP_IPC_LOCK in openbao.service is moot for the same reason. // STORAGE: raft, and this is now forced rather than chosen. OpenBao 2.6.0 // DEPRECATED the `file` backend for removal in v2.7.0, so picking `file` today buys // a migration within a release. Raft also has the only first-class backup primitive // (`bao operator raft snapshot save`), which is the disaster-recovery story. // // A single-node raft is the honest shape for a one-operator estate. Note that a // TWO-node raft would be strictly worse than one: quorum of two means losing either // node loses write availability. Three voters would be needed, and the desktop // (10.10.0.3) is intermittent, so it cannot be a reliable third. storage "raft" { path = "/var/lib/openbao" node_id = "vps2" } // Mesh-only. 10.10.0.1 is vps2's WireGuard address. There is deliberately no // 0.0.0.0 listener and no public DNS record — vps2 is a public VPS, and reaching // OpenBao should require being on the mesh, exactly like the Swarm control ports // and the Loki endpoint. listener "tcp" { address = "10.10.0.1:8200" tls_cert_file = "/etc/openbao/tls/bao.crt" tls_key_file = "/etc/openbao/tls/bao.key" } // Loopback, so a by-hand `bao` on the box works without the mesh address. listener "tcp" { address = "127.0.0.1:8200" tls_cert_file = "/etc/openbao/tls/bao.crt" tls_key_file = "/etc/openbao/tls/bao.key" } api_addr = "https://10.10.0.1:8200" cluster_addr = "https://10.10.0.1:8201" // ============================================================================ // SEAL: static auto-unseal. This is the crux of the whole design. // ============================================================================ // The `static` seal (OpenBao v2.4.0+) auto-unseals from a local key with no cloud // KMS and no HSM. OpenBao's own docs hedge it — "carefully evaluate use of Static // Key Auto Unseal" — but that caveat is written for multi-tenant enterprises. // // The decisive argument here is that static seal is NOT A DOWNGRADE FROM THE STATUS // QUO. Today /etc/thermograph/age.key is one file, 0400, root-owned, on each // rendering host, which decrypts every secret in the estate forever with no audit // trail. A static seal key at 0400 on vps2 is the IDENTICAL trust model: one file on // one box whose compromise is total compromise. What changes is everything layered // above it — per-consumer ACLs, an audit log, five-minute tokens, versioned rollback. // // WHY NOT SHAMIR: `bao operator init -key-shares=5 -key-threshold=3` means the vault // is sealed after every process restart — reboot, package upgrade, OOM kill — and a // human must type three shares. render-secrets.sh is on the deploy path for all three // environments, so a sealed vault blocks every deploy and every hotfix. With one // operator the N-of-M threshold is theatre: one person holds all the shares, so it // buys nothing against the real threat while costing an availability property the // estate currently has for free. Today a Contabo reboot needs no human at all. // Introducing a human-in-the-loop requirement that does not exist today is a // regression this estate cannot absorb. // // THE ONE NEW SINGLE POINT OF FAILURE this migration introduces: lose this key and // the raft snapshots become unrestorable. It MUST exist in at least two places, one // of them not on vps2. That is a hard operator obligation, not a suggestion. // // Rotation is supported n-1 via previous_key / previous_key_id. seal "static" { current_key_id = "20260801-1" current_key = "file:///etc/openbao/unseal.key" } // ============================================================================ // AUDIT: two devices, deliberately. // ============================================================================ // ⚠️ OpenBao STOPS ANSWERING REQUESTS ENTIRELY when no enabled audit device can // record them. That is correct behaviour for an audit log and a catastrophic way to // discover a full disk: a full /var on vps2 would block every deploy on the estate. // Two independent devices mean one wedging is survivable. // // Values are HMAC-SHA256'd in the log, so it is safe to ship to Loki through the // existing Alloy pipeline — which finally makes "who read which secret, when" a // queryable question. Today reading age.key leaves no trace whatsoever. // // logrotate on this path MUST signal with SIGHUP, or bao keeps writing to an // unlinked inode and the log silently stops growing. // OpenBao 2.6.1 requires `type` and `path` explicitly — the block label is the // device NAME, not its type — and device settings go in `options`. Written as // `audit "file" { file_path = ... }` the server refuses to start ("audit type must // be specified"); with type+path but a bare file_path it starts but silently // IGNORES the path and mode, which is the worse failure of the two. audit "file" { type = "file" path = "file/" options = { file_path = "/var/log/openbao/audit.log" mode = "0600" } } audit "syslog" { type = "syslog" path = "syslog/" }