The homelab (repo: ~/personal/nomad) runs a 2-node Nomad/Consul cluster (hermes 192.168.0.4, zeus 192.168.0.3 — Optiplex 7040s, both server+client, bootstrap_expect=2), a Synology DS920+ (dionysus, 192.168.0.5) providing Pi-hole DNS + CSI/NFS storage, and two Oracle always-free nodes: worker.cloud (Headscale control plane) and observability.cloud (nginx L4 relay + Gatus). Home WAN is DS-Lite (no public IPv4); all public traffic enters via the Oracle relay → Traefik pinned to hermes.
Why change — documented SPOFs and real outages:
- Pi-hole on dionysus is the acknowledged DNS SPOF; roaming tailnet clients reach it only via hermes's subnet route, so hermes down = DNS dead remotely and Gatus
internalgoes blind. - 2-node Raft tolerates zero failures. Traefik, the only subnet router, Immich PG primary, photo MySQL, and the Gatus heartbeat are all pinned to hermes.
- Every
Host()service is internet-reachable (both websecure entrypoints areasDefault). - No offsite backups; Vaultwarden (holds the Ansible vault password — circular recovery dependency) has no app-level backup; Headscale state exists only on worker.cloud.
- No Terraform; OCI security lists are hand-managed allow-all; Nomad has no ACLs/TLS;
headscale:latestwithpull: alwaysruns the control plane. - A UniFi Cloud Gateway Fibre arrives (~after 2026-09-12) on a new Blacknight line — public IPv4 suspected, unconfirmed; it obsoletes the self-hosted UniFi controller job.
User decisions (fixed): keep self-hosted Headscale on worker.cloud; add a 3rd Nomad+Consul server on an Oracle node over the tailnet; public services = static sites, Ghost photo site, Immich, Mumble, mediashare, Jellyseerr (everything else tailnet/LAN-only); design works under today's IPv6-only WAN with a decision gate at UCG cutover; IaC = Ansible + Terraform.
- DNS: two Pi-holes (hermes + zeus) via
external/pihole/docker-compose + Ansible (off-cluster, like headscale — DNS must not depend on the cluster it boots). Dionysus Pi-hole demoted then retired. UCG DHCP hands out both. Headscaledns.extra_records_pathserves*.dbyte.xyz→ answered locally by every tailnet client from the netmap (no subnet route, no upstream needed); global nameservers become both Pi-holes at their tailnet IPs. - Subnet routing: zeus becomes second subnet router; headscale ≥0.29 actively probes HA subnet routers and fails over (~15–30s) — fixes the exact "connected but not forwarding" outage mode.
- Ingress: public/private split via Traefik entrypoints —
websecure-proxiedlosesasDefault; public services opt in by tag; private routers get aninternal-onlyipAllowList middleware; private names lose public DNS. After UCG frees port 8443 on zeus: Traefik becomes asystemjob on hermes+zeus (per-node ACME host volumes, DNS-01 duplicates are within LE limits), relay does passive nginxbackupfailover between them. - Cluster: 3rd Consul+Nomad server (no client) on observability.cloud (not worker — keeps headscale-down and voter-down independent). All three advertise tailnet IPs (mixed addressing can't work; routing cloud→home via hermes's subnet route would put quorum behind hermes). Worst case headscale-down: cloud voter drops, home pair (direct LAN WireGuard, cached state) keeps 2/3 quorum.
- Security: Nomad gossip key + TLS + ACLs; Consul ACLs to default-deny; CA keys stay on zeus, never on the workstation; Traefik dashboard off insecure mode.
- Backups: app-level backups (vaultwarden sqlite, paperless export, headscale state) + nightly restic offsite to Backblaze B2 (not OCI object storage — Oracle already hosts the voter, exit nodes, control plane, and relay; backups must be failure-independent of Oracle account reclaim. ~pennies/month. Optional free OCI second copy via
restic copylater). Offline escape-hatch copy of 4–5 root secrets breaks the vaultwarden↔ansible-vault circle. - IaC:
terraform/oci(VCN, security lists tightened, instances imported withprevent_destroy) +terraform/cloudflare(records imported; per-name public records). State in OCI object storage S3-compat backend. - Monitoring: Gatus gains per-server raft/leader checks, per-node liveness, backup-freshness heartbeats (POST-on-success), resolver checks; the dead-man's-switch heartbeat job is unpinned from hermes (asserts "cluster can schedule + reach out", per-node checks catch single-node loss). One-time: free external uptime check on the Gatus status page (who watches the watcher).
Per user instruction: implement Phases 0, 1, and 2 now; log Phases 3–8 as TaskNotes tasks (via the task CLI) with due dates and a recommended Claude model per task.
What "implement" means from this machine (repo work now, operational steps logged as tasks where they need accounts/hosts I can't reach):
- Phase 0: all repo edits + commits (one commit per logical change, single subject line). The
vault_headplane_cookie_secretadd needs the live Consul KV value + Bitwarden vault password — attempted; if the cluster/bw is unreachable from this sandbox, logged as a task with exact commands. - Phase 1: all job specs / playbook / docs written and committed, with vaulted-secret placeholders documented. Creating the B2 account+bucket, running first backups, the restore test, the offline secret copy, and the post-verification dead-job cleanup are operational → logged as tasks.
- Phase 2: full Terraform code written (providers, import blocks, tightened security-list resources, backend config, README with bootstrap steps) +
.gitignoreentries so state/creds can't be committed. Running the state-bucket bootstrap,terraform plan/import, and the apply are operational → logged as tasks.
Model recommendations for the remaining phases (rationale: risk × judgment needed):
- Phase 3 (DNS layer) — Opus 5: multi-host migration with failure-mode reasoning, but well-specified here.
- Phase 4 (ingress split) — Sonnet 5: mechanical tag edits from an explicit list + verification.
- Phase 5 (third voter / raft surgery) — Fable 5: highest-blast-radius change in the plan; live quorum migration with per-step verification.
- Phase 6 (Nomad TLS/ACLs, Consul ACLs) — Fable 5 (or Opus 5 if supervised closely): phased security rollout where a wrong step locks you out.
- Phase 7 (UCG cutover) — Opus 5: mostly human-driven physical/console work; the model assists with config edits and the decision gate.
- Phase 8 (monitoring) — Sonnet 5: config additions from a checklist.
Two independent tracks until the UCG phase: Track N (network/DNS/ingress) and Track C (cluster/security/backups/IaC). Phase 1 (backups) must precede Phase 5 (raft surgery). Phase 2 (terraform: open UDP 41641) must precede Phase 5.
- Commit the uncommitted tailscale work as-is (
ansible/group_vars/tailscale.yaml,host_vars/{hermes,zeus}.yaml, untrackedhost_vars/worker.cloud.yaml,ansible/hosts,docs/Headscale.md) — clean baseline for later reverts. - Pin
external/headscale/docker-compose.yml→headscale/headscale:0.29.3, droppull: alwaysfor it (control plane on unpinned:latestis the scariest line in the repo; minor releases have one-way DB migrations). Pin traefik image injobs/traefik.hcl(currentlylatest+ force_pull). - Pin
ansible/requirements.yml; addcommunity.crypto,community.general. - Add missing
vault_headplane_cookie_secretto vaultedall.yaml(pull live value from Consul KVtailscale/headplane/secret, don't regenerate). - Fix
ansible/playbooks/gatus.yamlcurl|sh (copy the pipefail pattern from headscale.yaml). - Traefik dashboard: remove
insecure = true+ the :8081 static port/entrypoint; addapi@internalrouter ontraefik.dbyte.xyz, private entrypoint only. Update the Gatus traefik-ping check accordingly. - Replace hardcoded exit-node IP
100.64.0.7withtailscale_exit_node_use: "observability"(hostname resolves from netmap; verify on one host first).
- Vaultwarden (
jobs/vaultwarden/vaultwarden.hcl): companion task in the same group (CSI volume is single-node-writer) — dailysqlite3 .backup+ tar of attachments/sends/config/rsa keys →/backups/vaultwardenbind mount, keep 7, POST Gatus heartbeat on success. - Paperless (
jobs/paperless/paperless.hcl): companion task runningdocument_exporterdaily →/backups/paperless. - Headscale state (
ansible/playbooks/headscale.yaml): restic + systemd timer ON worker itself — sqlite.backupof db.sqlite + noise key + config → B2, straight over public internet (never via the tailnet it serves). Secrets in ansible-vault → root-only env file; never in Consul KV. - Offsite shipper: new
jobs/backup/restic-offsite.hclperiodic batch (05:00, after the existing 03–04:00 dumps), bind-mounts host/backupsro,restic backup→ B2 S3 endpoint,forget --keep-daily 14 --keep-weekly 8 --keep-monthly 6, Sunday prune, monthlycheck --read-data-subset=5%, heartbeat POST on success. Secrets via existing Consul-KV template pattern (seejobs/immich/immich-backup.hcl). Verify immich-postgres-backup dumps land under the same/backupsexport; repoint if not. - Escape hatch (one-time, manual): offline copy (paper/age-encrypted) of ansible vault password, restic passwords, B2 creds. New
docs/Disaster-Recovery.mdwith restore order: headscale from B2 → tailnet → vaultwarden sqlite → vault-password.sh works → rest. - Cleanup after verified restore test:
deletearchived 2026-09-23 —headscale.hcl(live-runnable, would split-brain the control plane),headscale-export.hclandheadscale-data.csi.hclmoved tojobs/archive/headscale/, which takes them out of the live jobs tree while keeping them recoverable if the restore test is still outstanding. Deleting outright, and deregistering the old CSI volume, remain to do once that test has passed.
- Layout:
terraform/oci/+terraform/cloudflare/(two simple roots, no modules). State: OCI Object Storage S3-compat backend (manual bootstrap bucket + customer secret key, versioning on). Optionalterraform/ns1/if the NS1 API key is handy; otherwise NS1 stays manual, noted. - OCI:
import {}blocks +-generate-config-out, hand-tidy.prevent_destroyon both instances. Tighten security lists (replace allow-all v4+v6): 22/tcp, 80/443/tcp, 64738 tcp+udp, 41641/udp (tailscale direct — required before Phase 5), ICMP/ICMPv6. Egress open. Create backup bucket (unused for now). Import reveals observability's shape → capacity gate for Phase 5 (if 1GB E2.1.Micro is too tight for consul+nomad servers, swap to always-free A1.Flex 1-OCPU/6GB — arm64 fine, it runs no workloads). - Cloudflare: import existing records. Verify the suspected
*.dbyte.xyzwildcard A record, then replace with per-name records at the relay IP for exactly the public set:immich,request,share,mumble,headscale,headplane.dbyte.xyz; the four vanity apexes + www;photo/admin-photo/apex james-hackett.ie;pint.ing. Private names → NXDOMAIN publicly. Keep wildcard DNS-01 certs (private hostnames stay out of CT logs).
Everything in this phase shipped on 2026-09-08 and was removed six days later. The Pi-hole pair,
ansible/playbooks/{pihole,dns}.yaml,external/pihole/,group_vars/pihole.yaml,scripts/sync-dns.shandjobs/pihole-backup.hclno longer exist; dionysus is the sole resolver again. The pair was not redundant in practice — both nodes received identical resolver surgery, so one nsswitch defect took out both at once and the Nomad cluster with them.docs/DNS.mdis the current description; the rest of this phase is kept as a record of what was tried and why it was undone. Any future revisit should start from that write-up, not from the bullets below.
- Two Pi-holes: new
external/pihole/docker-compose.yml(pinned image, 53 tcp/udp, web on 8053 — 80/8443 are Traefik's),FTLCONF_dns_listeningMode=all; newansible/playbooks/pihole.yaml(headscale.yaml shape: docker, compose sync, firewall allowing 53 from LAN + 100.64.0.0/10,DNSStubListener=no), new[pihole]group = hermes+zeus,group_vars/pihole.yamlholds adlists/upstreams/static.internalrecords as data (one-time Teleporter seed from dionysus). Config-as-code, no gravity-sync/nebula-sync (UI becomes read-only by convention; nebula-sync is a drop-in later if that chafes). - Generator:
scripts/sync-pihole-dns.sh→scripts/sync-dns.sh, sameHost()source of truth, three outputs: dnsmasq lines → both Pi-hole APIs;extra-records.json(A records → hermes/zeus tailnet IPs from the newtailnet_ipv4host_vars); static.internalrecords. Keep excluding headscale/headplane names. Wrapped in newansible/playbooks/dns.yaml: generate locally, push to both Pi-holes, atomic copy of the JSON toworker.cloud:/opt/headscale/data/extra-records.json(headscale hot-reloads via fsnotify — write temp + rename; issue #2753). - Headscale config: add
dns.extra_records_path+ changedns.nameservers.globalto both Pi-hole tailnet IPs. NB: deployed config is seededforce: false(headplane edits in place) — hand-apply to/opt/headscale/data/config.yaml+ restart container, mirror in repo copy. - Node resolv.conf: each home node points at the other node's Pi-hole first, own second. Keep the
/etc/hostsheadscale pins. - Subnet router #2:
tailscale_advertise_routes: "192.168.0.0/24"on zeus (accept_routes: falsealready set), approve via headscale —approve-routesreplaces the full list: automate inansible/playbooks/tailscale.yamlpost-play against[headscale], always computing the union of desired routes across hosts (kills the documented footgun). Test failover from the LAN only (never restart tailscaled over the tailnet): stop tailscaled on hermes, roaming client must reach 192.168.0.5 via zeus in ~15–30s. - Dionysus Pi-hole: tertiary in DHCP during 1–2 week soak → remove → shut down. Update
jobs/pihole-backup.hclto loop over both new instances (:8053/api/teleporter, now password-protected).
Shipped 2026-09-08 (branch phase3-redundant-pihole-split-dns); runbook in docs/DNS.md. Deviations from the above, each deliberate:
-
No general host firewall.
INPUTpolicy on both home nodes isACCEPTwith no rules, and there is no public IPv4 — adding one is a change with its own blast radius and belongs to its own phase. What did ship is narrower and load-bearing: a port-53-only ruleset (/opt/pihole/dns-firewall.sh+ systemd unit ordered after docker and tailscaled) because the DS-Lite line gives br0 a globally routable IPv6 address and FTL binds the wildcard, i.e. without it this is an open resolver the moment the router's inbound policy changes. -
tailscale_accept_dns: falseadded on hermes and zeus — not in the plan, and required. A node whose resolution comes from MagicDNS whose upstream is that node's own Pi-hole is a loop, and tailscaled would overwrite the static/etc/resolv.confthe playbook writes. That costs*.ts.dbyte.xyzresolution on those nodes, which theobservability.ts.dbyte.xyzheartbeats injobs/depend on, so the Pi-holes now carry static records for every inventory host's MagicDNS name (generated fromtailnet_ipv4) plusserver=/ts.dbyte.xyz/100.100.100.100for anything else. -
server=/consul/127.0.0.1#8600is emitted bysync-dns.sh. Disabling the resolved stub listener removes how.consulresolved for both the host and every container (docker daemondnsis 172.17.0.1). It has to live indnsmasq_linesbecause that array is replaced wholesale on every apply. -
Static
.internalrecords stayed ingroup_vars/pihole.yaml, not in the generator. They are not derived fromHost()rules, and two owners for one setting is how drift starts. -
pihole_fallback_resolverskeeps dionysus as each node's third resolver through the soak, which also removes the bootstrap gap: during the first rollout the node whose Pi-hole does not exist yet still resolves. -
Nodes have 16GB, not the 8GB the risk list assumes — the headroom concern does not apply.
-
dns.nameservers.globalstays exactly the two Pi-holes (decision 2026-09-09, after a live outage during the build: the home link dropped and the laptop lost all DNS, public names included, because MagicDNS's only global nameserver was 192.168.0.5 behind hermes's subnet route). A public third entry was considered and rejected in favour of a documented break-glass indocs/DNS.md—tailscale set --accept-dns=falseon the client, or a hand-added resolver on worker — so nothing standing bypasses the blocker. -
IPv6 is the only native transit at home (established 2026-09-09): Virgin Media runs the line as DS-Lite with the IPv4-in-IPv6 tunnel disabled on their side, so the only IPv4 egress is via a tailscale exit node. That makes the chain
native IPv6 → headscale → tailnet → exit node → IPv4one-directional, and the IPv6/etc/hostspin for headscale load-bearing rather than belt-and-braces. Consequences: never disable IPv6 on hermes/zeus to work around a v6 fault; the router must advertise SLAAC (stateful DHCPv6 clears the autonomous flag and the nodes, which run no DHCPv6 client, lose their address while keeping the RA default route); and the hardcoded Oracle VCN AAAA inpihole_headscale_pinsmust be updated before it ever changes. Phase 7's UCG cutover is what ends this dependency.
Still to do at cutover: point DHCP at 192.168.0.4/.3, seed the Teleporter export from dionysus, hand-apply the dns: block to worker.cloud:/opt/headscale/data/config.yaml (headplane edits it in place, so the repo copy is not deployed), and put vault_pihole_password in the vault and Consul KV pihole/password.
jobs/traefik.hcl: removeasDefault = truefromwebsecure-proxied(keep onwebsecure); addinternal-onlyipAllowList middleware to the file provider (127.0.0.1/32, 192.168.0.0/24, 100.64.0.0/10, fd7a:115c:a1e0::/48) — never attach it to relay-facing routers (PROXY protocol = real public IPs).- Public jobs add
traefik.http.routers.<name>.entrypoints=websecure,websecure-proxied:jobs/web/{website,ihatenixos,crazybitta,nicecocks,pinting,mediabrowse}.hcl,jobs/photo-site/photo.hcl(both routers incl. photo-analytics),jobs/immich/immich.hcl,jobs/jellyseerr.hcl. Mumble already explicit. File-providerphoto-activitypub/photo-wellknown→ both entrypoints;synodrive/video→internal-only. - Private jobs (vaultwarden, paperless, actual-budget, home-assistant, molecule, gerry) add
middlewares=internal-only@file. Verify gerry receives no inbound webhooks (nothing in-repo suggests it; check the gerry source) — if it does, it moves to the public list. - Gatus
appsgroup: private names lose public DNS; observability is a tailnet member so checks resolve via extra_records → verify the Gatus container inherits tailnet DNS, else pin those endpoints to the Traefik tailnet IP with a Host header. Deliberate improvement: checks now exercise the path real private clients use.
- Voter on observability.cloud, Consul server AND Nomad server, no Nomad client (
nomad_client_enabled: falsehost var gates client config/docker/host-volume tasks inconfigure-nomad-consul.yaml).non_voting_serveris Enterprise-only and unwanted anyway. - Addressing: new
tailnet_ipv4host_var per node = single source of truth. Consulbind_addr→GetInterfaceIP "tailscale0"; Nomadadvertise{}→{{ tailnet_ipv4 }};retry_joinlists built from host_vars (IPs, not names — no DNS dependency at daemon start). Nomad clientnetwork_interfacepinned to the LAN interface so allocation/service addresses stay LAN (Traefik/consul catalog unaffected; node-level.node.consulanswers become 100.64/10 — nothing may depend on those). - Harden the new coupling:
Wants/After=tailscaled.service+ wait-for-tailscale-IPExecStartPrein both service units; doc + comment: tailscaled restarts on cluster members are one-node-at-a-time over LAN/public SSH only (management paths already avoid the tailnet). - Migration (each step verified via
consul operator raft list-peers/nomad operator raft list-peers): add observability to[nomad](keep zeus first / set explicitconsul_ca_host: zeus);bootstrap_expect: 3in the same rollout (inert for nodes with existing raft state, prevents fresh split-brain); make repo/playbooks arch-aware (cloud node may be arm64); roll zeus → verify → roll hermes → verify → join observability as third voter; confirmnomad node statusstill shows only hermes+zeus as clients. - Rollback: revert to LAN advertise + expect 2, restart home pair one at a time, stop cloud daemons,
raft remove-peerif autopilot doesn't reap. - Circular-dependency reasoning (documented in docs/): headscale down → cloud voter may drop (DERP decay per the August outage) → home pair keeps 2/3 quorum on direct LAN WireGuard with cached state. Voter on worker would correlate headscale-down with voter-down — that's why it's on observability.
Order: gossip key → TLS → Nomad ACLs → Consul ACLs. Each step independently revertible.
- Nomad gossip
encryptkey (verified absent): one-shot all-three-servers restart window. - Fix CA handling both stacks: stop fetching the Consul CA private key to the workstation (
configure-nomad-consul.yamlfetch loop); play-scoped tempfile +always:cleanup; shred/tmp/consul-tlsonce. New Nomad CA vianomad tls ca createon zeus; CA keys live root-only on zeus. - Nomad TLS rollout via
rpc_upgrade_mode(servers accept mixed → clients TLS → servers strict + HTTPS); updateNOMAD_ADDR/NOMAD_CACERT, scripts, Gatus checks. - Nomad ACLs: enable, bootstrap, management token → vaultwarden + offline copy; no anonymous policy (default deny); one operator token vaulted for automation.
- Consul ACLs: enable with
default_policy=allow→ bootstrap → node-identity agent tokens →nomad-agenttoken (service/node write, key_prefix read — keeps every existing{{ key }}template working) → Traefik consulCatalog read token via KV → anonymous policy gets node/service read (keeps.consulDNS + Pi-hole forwarding working) → flip todefault_policy=deny→ verify DNS, Traefik, a KV-templated alloc restart, backups.
- Export self-hosted controller backup FIRST; adopt/import into the UCG. DHCP: DNS = 192.168.0.4 + .3 (+ .5 during soak), static leases, disable UCG DNS interception/ad-block (Pi-hole is the resolver of record).
- Retire
jobs/unifi/unifi.hcl→jobs/archive/; deregisterunifi/unifi-mongoCSI volumes after UCG confirmed; removeunifi.dbyte.xyzrecords;insecure@fileserversTransport loses its consumer (keep only if proxying the UCG UI). - Port 8443 on zeus now free → dual Traefik:
jobs/traefik.hcl→type = "system"+ constraint${attr.unique.hostname}regexphermes|zeus(belt-and-braces vs future cloud clients); ACME moves from ephemeral_disk to a per-node host volume (per-node stores — Traefik CE can't share ACME; two DNS-01 issuers of the same wildcards is within LE duplicate limits); relaystream.conf.j2getsupstreamblocks: hermes primary,zeus backup(max_fails=2 fail_timeout=10s; verified supported in stream module).sync-dns.shemits both node IPs for internal + tailnet records. No keepalived — relay covers public failover, dual A records cover internal; add keepalived later only if some LAN client handles multi-A badly. - Decision gate — probe the UCG WAN address externally:
- Public IPv4 → home becomes primary ingress (firm recommendation): UCG port-forwards 80/443/64738 → hermes; direct traffic arrives on
websecurewith real source IPs (no PROXY protocol needed); public per-name records flip to home IP, TTL 120s, grey-cloud (Mumble is L4 — cannot orange-cloud). Rationale: Irish users currently detour via Oracle London (~25–35ms + WG overhead + shape bandwidth cap); the relay path chains observability ∧ tailnet ∧ home ∧ Traefik and the tailnet layer is the least reliable link per the two 2026 outages. Relay stays warm standby; failover =scripts/failover-ingress.shflipping records via Cloudflare API (creds already in Consul KV; ddclient job is prior art), triggered manually off Gatus alerts; ~2–5 min budget. If the Blacknight IP proves dynamic, add a ddclient-style updater or stay relay-primary until lease stability is observed. - Still CGNAT → relay stays primary; everything else already stands alone. No second relay (Caddy owns 80/443 on worker); revisit only if single-relay availability actually hurts.
- Public IPv4 → home becomes primary ingress (firm recommendation): UCG port-forwards 80/443/64738 → hermes; direct traffic arrives on
- Revert the TEMPORARY exit-node hack once home IPv4 exists: hermes re-advertises (
tailscale_exit_node: true, dropexit_node_useon hermes+zeus), re-approve hermes routes with the full list (192.168.0.0/24,0.0.0.0/0,::/0).
In external/gatus/config.yaml (+ playbook rerun):
- Per-server:
GET :8500/v1/status/leader(unauthenticated even under deny)[BODY] != ""+ TCP :4647, all three servers over tailnet. Per-node liveness (TCP/ICMP) for hermes+zeus. - Unpin
jobs/gatus-heartbeat.hclfrom hermes — dead-man's-switch asserts "cluster schedules + reaches out"; per-node checks catch single-node loss; both-down still trips it. - Backup freshness: three
external-endpointswithheartbeat.interval: 26h(backups_offsite,backups_vaultwarden,backups_headscale— the last posted over the public internet from worker). POST-on-success only; no restic-polling from Gatus. - DNS checks against both Pi-holes (tailnet IPs), HTTP per Traefik node once dual.
- One-time manual: free external uptime check (UptimeRobot/healthchecks.io) on the public Gatus page — observability now carries alerting and a voter. Document in docs/.
- Update docs: new cluster-topology section, DNS architecture, DR runbook cross-links; retire stale
br0references.
| Area | Files |
|---|---|
| Cluster templates | ansible/playbooks/templates/{consul/consul.hcl.j2,consul/consul.service,nomad/nomad-base.hcl.j2,nomad/nomad-server.hcl.j2,nomad/nomad-client.hcl.j2,nomad/nomad.service}, ansible/playbooks/configure-nomad-consul.yaml |
| Inventory/vars | ansible/hosts, ansible/group_vars/{all,tailscale,pihole(new)}.yaml, ansible/host_vars/*.yaml (new tailnet_ipv4, nomad_client_enabled) |
| DNS | ansible/playbooks/hosts-records.yaml, external/headscale/config.yaml, docs/DNS.md — the Phase 3 files (external/pihole/, ansible/playbooks/{pihole,dns}.yaml, scripts/sync-dns.sh) were deleted on 2026-09-14 |
| Ingress | jobs/traefik.hcl, per-job tag edits (public list above), ansible/playbooks/templates/relay/stream.conf.j2, ansible/group_vars/relay.yaml |
| Backups | jobs/vaultwarden/vaultwarden.hcl, jobs/paperless/paperless.hcl, jobs/backup/restic-offsite.hcl (new), ansible/playbooks/headscale.yaml, docs/Disaster-Recovery.md (new) |
| Terraform | terraform/{oci,cloudflare}/ (new) |
| Monitoring | external/gatus/config.yaml, jobs/gatus-heartbeat.hcl |
- Backups: run each job once manually (
nomad job dispatch/ systemdstart),restic snapshotsshows all three sources, then a full test restore of vaultwarden sqlite + headscale db to a scratch dir; Gatus heartbeats green. - Terraform:
terraform planclean after import (no-op); external port probe confirms only the intended ports answer on both Oracle IPs. - DNS:
dig vault.dbyte.xyz @192.168.0.4and@192.168.0.3(LAN answers);tailscale dns query vault.dbyte.xyzfrom a roaming client (extra_records answer, no subnet route); ad domain returns blocked from both; stop tailscaled on hermes (from LAN!) → roaming client still resolves + reaches 192.168.0.5 via zeus; Gatusinternalstays green during that test. - Ingress split: from an external network, private names NXDOMAIN + direct-IP requests with private Host headers get 4xx from the relay path; public list still serves; from tailnet, everything serves.
- Quorum:
raft list-peers×2 show 3 voters; stop consul+nomad on any one node → cluster still schedules a test job; restart tailscaled on observability → voter rejoins. - Security: unauthenticated
nomad statusfails; with token succeeds over TLS;dig @consulstill answers; a KV-templated alloc restarts cleanly under deny. - UCG gate: external probe of home WAN; after flip,
mtr/curl timing from an external Irish vantage vs the relay path; kill Traefik on hermes → relay serves via zeus withinfail_timeout.
- B2 introduces a tiny paid dependency (~pennies/mo) — chosen over OCI object storage for failure independence from the Oracle account that now hosts the voter, relay, control plane, and exit nodes. Swap to an OCI bucket if unacceptable, accepting correlated loss.
- Observability's shape is unknown until the terraform import; if 1GB E2.1.Micro, consul+nomad servers fit but tight (
GOMEMLIMIT); escape valve = always-free A1.Flex swap. *.dbyte.xyzwildcard A record in Cloudflare is inferred (Gatus reaches vault.dbyte.xyz publicly), not verified — confirm before Phase 4's DNS step.- Gerry inbound-webhook question — confirm against the gerry source before leaving it private.
- Headplane edits the deployed headscale config in place; repo copy ≠ deployed copy — every dns-block change is a hands-on edit + restart on worker.
- Cluster health now rides tailscale0 on all three nodes: the never-restart-tailscaled-over-the-tailnet rule becomes a quorum rule; mitigated by systemd ordering, LAN/public management paths, and docs.
- The two home nodes are 8GB and near capacity; this plan adds ~250MB/node (Pi-hole + second Traefik) and nothing heavier — recheck headroom after commit 07daeb2's right-sizing.
- Pi-hole UI becomes config-as-code read-only by convention; drift is possible if UI edits happen — nebula-sync is the drop-in if that workflow is wanted.