Environments stop being machines. Until now each box WAS an environment --
"beta" named both a deploy target and a host, "the desktop" named both the
operator's computer and the dev server -- so every path could assume one
environment per host and hardcode /opt/thermograph, /etc/thermograph.env and
ports 8137/8080. That assumption ends here:
vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV
(own Postgres, mesh-only on 10.10.0.2:8137)
vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one
TimescaleDB instance, plus Centralis, Postfix, backups
desktop AI model hosting + flex Swarm capacity, no environment
Nothing live has moved; the ordered cutover is in
infra/deploy/RUNBOOK-vps1-vps2-cutover.md.
deploy/env-topology.sh is the single source of truth: env -> host, checkout,
branch, deploy mode, stack name, env file, LB ports, DB role/database, service
prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing
distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it
disagrees with the checkout it was invoked from. The host-wide secrets-env and
deploy-mode markers survive only as a fallback for a single-environment box.
Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the
db service, and beta having no database of its own is the point). Its services
are prefixed beta-*: Swarm registers a service's short name as a DNS alias on
every network it joins, so two stacks both calling a service `web` on the shared
data network would let prod's frontend resolve a beta task. Prod's stack, env
and LB config are untouched.
One Postgres, two databases with two roles: deploy/db/provision-env-db.sh
creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT
revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for
the environment that owns the instance -- doing so would demote prod's bootstrap
superuser.
Fixes that co-residency would otherwise have broken silently:
- CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and
also "the Forgejo box" because those shared a machine; that conflation is what
once had the prod backup dumping beta.
- The nightly backup dumps BOTH application databases to separate off-box
prefixes, and fails loudly on a missing one instead of skipping it.
- Alloy stopped deriving the `host` label from the node -- on vps2 that would
have filed every beta line as prod, feeding prod's alert rules with beta's
traffic. It is now derived per source, with a new `node` label for the machine,
and beta's log volume is mounted via a vps2-only overlay.
- ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH
target and env-file path from the topology instead of hardcoding beta to
75.119.132.91 -- which is vps1's address now.
- Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on
vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of
unreviewed branches on a VPS.
Terraform's hosts variable is keyed by machine with a nested environments map,
and main.tf flattens (host, environment) pairs so two environments on one box
cannot share a checkout. Caddy's single reference config is split per host.
Docs, onboarding and the runbooks are updated throughout, including the security
rationales that co-residency makes false: separate SSH credentials no longer put
a host boundary between beta and prod, and the boundary that remains is the
database and the filesystem.
4.6 KiB
3-node Docker Swarm (vps2 + vps1 + desktop)
This Swarm mesh's only Swarm-scheduled workload is Forgejo
(deploy/forgejo/), pinned to vps1. It does not orchestrate the
Thermograph app the same way — prod and beta each run as their own docker stack deploy (deploy/stack/thermograph-stack.yml /
thermograph-beta-stack.yml) that happens to land on this same manager node
(vps2) because their volumes are local to it today. Keeping Forgejo's stack
and the app stacks conceptually separate means nothing here can strand or
interfere with the app's single-writer Postgres/TimescaleDB.
This is the canonical topology from
thermograph-docs/runbooks/implementation-handoff.md (Track B steps 2-3) — three nodes,
not two. The desktop (formerly the LAN dev machine) joins as a worker for flex
capacity and AI-model hosting; it hosts no Thermograph environment.
Nodes:
- manager — vps2 (
169.58.46.181), the box with headroom for prod's and beta's Swarm stacks and their local volumes. - worker — vps1 (
75.119.132.91), pinned to run Forgejo (node.labels.role == forge). Also runs Grafana/Loki/Alloy and the dev environment, both outside this Swarm cluster (plain Docker/compose on the same host's daemon, not Swarm-scheduled). - worker — desktop (this machine), flex capacity plus AI-model hosting. No Thermograph environment runs here.
One manager, not more: Raft needs 3 nodes for real quorum-based HA, and this cluster only has 3 nodes total, so making even one more of them a manager would still fall short of real HA while adding split-brain risk. If the manager (vps2) goes down, the workers keep running whatever was already scheduled on them (Forgejo, pinned to vps1) but the cluster can't reschedule anything until vps2's back — acceptable for a small cluster whose only Swarm-scheduled job is CI/CD.
Order of operations
- Agent access first (
deploy/provision-agent-access.sh) on vps1 and vps2 — everything below on those two boxes is run through that access. The desktop is wherever you're already working from; no separate access step needed there. - WireGuard mesh (
setup-wireguard.sh <my_wg_ip> <peers_file>) — run on all three nodes. See the script's header for the peer-list format and the two-pass key-exchange dance (pubkeys aren't known until every node has run it once). Verify withping <peer_wg_ip>to each of the other two before continuing. - Swarm init (
init-swarm.sh <manager_wg_ip>) on the manager (vps2) only. - Swarm join (
join-swarm.sh <manager_wg_ip> <token>) on each of the two workers (vps1, desktop) — same token for both. - Firewall lockdown (
firewall-swarm.sh) on all three nodes — closes 2377/7946/4789 to everything except the WireGuard interface. Do this after joining is confirmed working on all three, not before (locking the ports first would make the join itself fail). - Label vps1 (
label-forge-node.sh <vps1-node-name>) on the manager —docker node lsshows each node's name/ID. Only vps1 getsrole=forge; the desktop and vps2 don't need a Swarm label for anything in this setup. - Deploy Forgejo: see
deploy/forgejo/README.md. - Register the Actions runner:
deploy/forgejo/register-lan-runner.sh. See that document (andDEPLOY-DEV.md) for where it actually runs today — its own header comment predates the vps1/vps2 rename and still describes "the desktop" as the canonical placement.
Why WireGuard instead of relying on Swarm's built-in TLS alone
Swarm's control plane (port 2377) is TLS-encrypted and mutually authenticated by default. Its overlay data plane (VXLAN, port 4789) is not encrypted by default, and Docker's own guidance is that port must never face the public internet — these nodes are on different networks (two separate providers' public IPs, plus a home/LAN connection for the desktop), not one private LAN, so the tunnel is the network boundary the Swarm ports advertise into, rather than trusting the public internet (or the desktop's home network) directly.
Verifying
# On the manager (vps2):
docker node ls # all three nodes Ready
docker node inspect <vps1-node> --format '{{.Spec.Labels}}' # role:forge
# From a FOURTH machine outside the mesh entirely, confirm the Swarm ports
# are NOT reachable on either VPS's public IP (the desktop has no public IP
# to check this way):
nc -zv -w2 <vps1_or_vps2_public_ip> 2377 # should fail/timeout
nc -zvu -w2 <vps1_or_vps2_public_ip> 4789 # should fail/timeout
Adding a node label back out (undo)
docker node update --label-rm role <vps1-node>