#!/usr/bin/env bash # WHERE EACH ENVIRONMENT LIVES — the single source of truth, sourced by every # deploy path. Not executable on its own; `. env-topology.sh` then call # `thermograph_topology `. # # Why this file exists at all. Until the vps1/vps2 split, "which environment is # this?" was answerable from the machine you were standing on: one host ran one # environment, so a host-wide marker (/etc/thermograph/secrets-env) and a # host-wide deploy mode (/etc/thermograph/deploy-mode) were enough, and every # path could hardcode /opt/thermograph, /etc/thermograph.env, ports 8137/8080. # vps2 now runs BOTH beta and prod. Every one of those assumptions becomes a # collision: two checkouts, two rendered env files, two stacks, two loopback # port pairs, two image-tag files, two deploy locks — on one box. So the # environment is now an INPUT (THERMOGRAPH_ENV, passed by the deploy caller), # and everything else is derived here rather than re-guessed per script. # # The host markers survive as the fallback for a by-hand run with no explicit # env (`deploy.sh` on prod still means prod), but they are no longer the thing # that decides where files go. # # THE ESTATE # # vps1 75.119.132.91 10.10.0.2 "operational programs" # Forgejo (git + CI + registry), Grafana/Loki/Alloy, emigriffith.dev, # and the DEV environment with its own Postgres. Nothing here is # reachable by an external user except git/dashboard/the portfolio site; # dev itself is mesh-only, deliberately (see DEV_BIND_ADDR below). # # vps2 169.58.46.181 10.10.0.1 "the deployed environment" # Everything an external user can touch: prod (thermograph.org) AND beta # (beta.thermograph.org), Centralis, Postfix, the backups, and the ONE # TimescaleDB instance that serves both environments on separate # databases with separate roles. # # desktop 10.10.0.3 operator's box # AI model hosting (voice-to-text, the LLM behind upcoming features) and # flex capacity as a Swarm worker. It hosts NO Thermograph environment — # that is the whole point of the vps1/vps2 split. A laptop-style # `make dev-up` still works locally; it is just not the dev server. # # WHY BETA SITS NEXT TO PROD RATHER THAN NEXT TO DEV # # Beta is a pre-production rehearsal, so what it needs to rehearse is prod's # environment: the same orchestrator (Swarm, not compose), the same Postgres # major and extension build, the same Caddy in front, the same mail path, the # same mesh position. Co-locating beta with dev bought resemblance to the thing # it is NOT trying to predict. Co-locating it with prod means a beta green light # is evidence about prod. The cost — a bad beta deploy shares a machine with # prod — is bounded by the per-environment isolation this file defines: separate # stacks, separate volumes, separate DB roles, separate CPU limits. # thermograph_topology # # Exports the TG_* variables describing that environment. Every value is a # derived fact about the estate, not a preference: change one here and the # deploy scripts, the stack files and the runbooks all follow. thermograph_topology() { local env_name="${1:?thermograph_topology needs an environment: dev|beta|prod}" # Reset first: this function is sourced into long-lived shells (deploy.sh # sources it before and after its self re-exec), and a stale TG_STACK_NAME # from a previous call would silently target the wrong stack. TG_ENV=""; TG_HOST=""; TG_APP_DIR=""; TG_BRANCH=""; TG_DEPLOY_MODE="" TG_STACK_NAME=""; TG_STACK_FILE=""; TG_LB_CONFIG="" TG_COMPOSE_PROJECT=""; TG_COMPOSE_FILE="" TG_ENV_FILE=""; TG_STACK_ENV_FILE=""; TG_LB_NAME="" TG_LB_HTTP_PORT=""; TG_LB_FE_PORT=""; TG_DB_NAME=""; TG_DB_USER="" TG_TAGS_FILE=""; TG_LOCK_FILE=""; TG_BIND_ADDR=""; TG_SKIP_COMMON=0 TG_SVC_PREFIX=""; TG_DATA_NETWORK=""; TG_DB_SERVICE=""; TG_POST_DEPLOY=0 TG_SSH_HOST=""; TG_SSH_TARGET="" case "$env_name" in prod) TG_ENV=prod TG_HOST=vps2 # Unchanged from before the split — prod keeps every path it already has, # so nothing about the live prod host moves during the cutover. TG_SSH_HOST=169.58.46.181 TG_SSH_TARGET=agent@169.58.46.181 TG_APP_DIR=/opt/thermograph TG_BRANCH=main TG_DEPLOY_MODE=stack TG_STACK_NAME=thermograph TG_STACK_FILE=deploy/stack/thermograph-stack.yml TG_ENV_FILE=/etc/thermograph.env TG_STACK_ENV_FILE=/etc/thermograph/stack.env TG_LB_NAME=thermograph-lb TG_LB_CONFIG=deploy/stack/lb/Caddyfile TG_LB_HTTP_PORT=8137 TG_LB_FE_PORT=8080 TG_DB_NAME=thermograph TG_DB_USER=thermograph # Prod's services keep their bare names (web, worker, frontend, ...): the # cutover must not touch prod's stack file, its env or its LB config. TG_SVC_PREFIX="" # Where the database lives. Prod's own overlay, which is `attachable` and # which beta joins as an external network. TG_DATA_NETWORK=thermograph_internal TG_DB_SERVICE=thermograph_db # Post-deploy city warm + IndexNow ping. Prod only — see the beta note. TG_POST_DEPLOY=1 ;; beta) TG_ENV=beta TG_HOST=vps2 # Same box, same public IP as prod — beta and prod are two stacks on one # Swarm manager now, not two separate hosts. A beta SSH target is # therefore prod's blast radius too (see .claude/hooks/prod-guard.sh). TG_SSH_HOST=169.58.46.181 TG_SSH_TARGET=agent@169.58.46.181 # A SECOND checkout on the same box. Separate from prod's so the two can # sit on different commits of this repo, and so `git reset --hard` in one # deploy can never yank the tree out from under the other's running # deploy. Everything below is likewise a distinct name/port/path from # prod's, on purpose: co-location is only safe if nothing is shared by # accident. The one deliberate exception is the database SERVER (see # TG_DB_* — separate role and database on a shared instance). TG_APP_DIR=/opt/thermograph-beta TG_BRANCH=main TG_DEPLOY_MODE=stack TG_STACK_NAME=thermograph-beta TG_STACK_FILE=deploy/stack/thermograph-beta-stack.yml TG_ENV_FILE=/etc/thermograph-beta.env TG_STACK_ENV_FILE=/etc/thermograph/beta-stack.env TG_LB_NAME=thermograph-beta-lb TG_LB_CONFIG=deploy/stack/lb/Caddyfile.beta # NOT 8137/8080: prod's loopback LB already owns those on this host. TG_LB_HTTP_PORT=8237 TG_LB_FE_PORT=8180 TG_DB_NAME=thermograph_beta # Its own role, not prod's `thermograph` superuser-of-its-own-database. # One instance is a capacity decision; it is not a decision to let a beta # deploy running an unmerged branch read or write the production database. TG_DB_USER=thermograph_beta # Beta's Swarm services are named beta-web, beta-worker, ... Swarm # registers a service's SHORT name as a DNS alias on every network it # joins, so two stacks that both call a service `web` on one shared # network make `web` ambiguous — prod's frontend could resolve a beta # task. Prefixing beta's names removes the collision without editing a # single line of prod's stack. TG_SVC_PREFIX="beta-" # Beta has no db service of its own: it joins prod's overlay (declared # `external` in its stack file) purely to reach `db`, and keeps its own # `internal` overlay for beta-to-beta traffic. TG_DATA_NETWORK=thermograph_internal TG_DB_SERVICE=thermograph_db # No warm, no IndexNow on beta. IndexNow announces URLs to Bing/DDG/ # Yandex — from beta that means asking search engines to index # beta.thermograph.org, which is the opposite of what beta is for. The # city warm is worse than useless here: it spends the shared upstream # archive quota to fill a cache that only a rehearsal environment reads. # (Both ran on beta under compose; that was a side effect of beta and # prod sharing one script, not a decision.) TG_POST_DEPLOY=0 ;; dev) TG_ENV=dev TG_HOST=vps1 # vps1 — Forgejo/Grafana/dev, mesh-only for dev itself (see TG_BIND_ADDR). TG_SSH_HOST=75.119.132.91 TG_SSH_TARGET=agent@75.119.132.91 TG_APP_DIR=/opt/thermograph-dev # The only environment that tracks `dev`; beta and prod both run this # repo's `main` (infra is not environment-staged — app code is, via image # tags). See the branch model in infra/README.md. TG_BRANCH=dev # Compose, not Swarm: dev's value is a fast, legible, single-box stack # you can `docker compose logs` at. It is the one environment not # pretending to be prod. TG_DEPLOY_MODE=compose TG_COMPOSE_PROJECT=thermograph-dev TG_COMPOSE_FILE="docker-compose.yml:docker-compose.dev.yml" TG_ENV_FILE=/etc/thermograph.env # Dev keeps its OWN Postgres container (a `db` service in its compose # project). It is not on the shared instance and must not be: the shared # instance lives on vps2 and holds real user data. TG_DB_NAME=thermograph TG_DB_USER=thermograph # Mesh-only. vps1 is a public VPS, so the dev overlay must not publish on # 0.0.0.0 the way it did on the operator's LAN box — dev runs whatever # branch is in flight, including unreviewed ones. Reachable at # http://10.10.0.2:8137 from anything on the WireGuard mesh, and from # nowhere else. There is deliberately no dev DNS record and no Caddy site. TG_BIND_ADDR=10.10.0.2 # Dev NEVER layers common.yaml — that file is the internet-facing fleet's # shared production credential set (both S3 keypairs, the VAPID signing # key, REGISTRY_TOKEN, the metrics token), and dev runs whatever branch is # in flight on the same box as Forgejo and its CI runner. This lives here # rather than only in deploy-dev.sh so the protection is a property of the # ENVIRONMENT, not of which entry point happened to be used. See the long # note in render-secrets.sh. TG_SKIP_COMMON=1 TG_DB_SERVICE=db # Dev warms nothing and pings nothing: it must never spend the upstream # archive quota, and it must never tell a search engine it exists. TG_POST_DEPLOY=0 ;; *) echo "!! unknown environment '$env_name' (expected dev|beta|prod)" >&2 return 2 ;; esac # Derived, never hand-set: keeping these next to the definitions above is what # stops a second environment on one host from sharing a lock or a tag file. TG_LOCK_FILE="$TG_APP_DIR/infra/deploy/.deploy.lock" if [ "$TG_DEPLOY_MODE" = stack ]; then TG_TAGS_FILE="$TG_APP_DIR/infra/deploy/.stack-image-tags.env" else TG_TAGS_FILE="$TG_APP_DIR/infra/deploy/.image-tags.env" fi export TG_ENV TG_HOST TG_APP_DIR TG_BRANCH TG_DEPLOY_MODE export TG_STACK_NAME TG_STACK_FILE TG_LB_CONFIG TG_COMPOSE_PROJECT TG_COMPOSE_FILE export TG_ENV_FILE TG_STACK_ENV_FILE TG_LB_NAME export TG_LB_HTTP_PORT TG_LB_FE_PORT TG_DB_NAME TG_DB_USER export TG_TAGS_FILE TG_LOCK_FILE TG_BIND_ADDR TG_SKIP_COMMON export TG_SVC_PREFIX TG_DATA_NETWORK TG_DB_SERVICE TG_POST_DEPLOY export TG_SSH_HOST TG_SSH_TARGET } # thermograph_env_name # # Which environment is this run for? Explicit input first (the deploy workflows # pass THERMOGRAPH_ENV, and on vps2 it is the ONLY thing distinguishing a beta # deploy from a prod one), then the host marker for a by-hand run on a # single-environment box. Empty output means "cannot tell" and the caller must # fail rather than guess — guessing wrong on vps2 means deploying beta's image # tag onto prod. thermograph_env_name() { local marker="${THERMOGRAPH_SECRETS_ENV_FILE:-/etc/thermograph/secrets-env}" if [ -n "${THERMOGRAPH_ENV:-}" ]; then printf '%s\n' "$THERMOGRAPH_ENV" return 0 fi cat "$marker" 2>/dev/null || true }