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.
11 KiB
10. Recipes
Task-shaped walkthroughs. Each one names the files you'll touch and the traps specific to that change.
Add a field to a graded API payload
- Build it in
backend/api/payloads.py(orcontent_payloads.pyfor the SSR content API). Payload assembly is pureinputs → dict; keep HTTP semantics with the caller. - Bump
PAYLOAD_VERinapi/payloads.py. Non-negotiable — the derived store is token-validated with no clock expiry, so without a bump the old shape serves out of cache forever, silently. If the change is content-API only,CONTENT_VERis the narrower lever. - Decide whether it's breaking. Additive → fine on
v2. Breaking → a newv3router mounted alongsidev2re-registering only the changed handlers, plusAPI_CONTRACT_VERSIONbumped in the same PR. - Check
/cell. If the field lands in a slice, the slice→view map infrontend/static/cache.jshas to track it. - Consume it in
frontend/server/internal/contentapi/types.goand whichever template or JS renders it. - Test both sides:
cd backend && make test, thencd frontend/server && go test ./.... - Refresh fixtures if the shape moved:
cd frontend && make capture-fixturesagainst a live backend. Those fixtures feed the Go tests too.
Add a new SSR page
The backend owns the page's data and metadata; the frontend owns its markup.
backend/api/content_payloads.py— a builder returning JSON-safe dicts (raw floats, noMarkup, no ContextVar unit rendering), includingpage_title,canonical_path,breadcrumbandjsonld. Those live here, not in the frontend.backend/api/content_routes.py— route wiring plus the same derived-store / ETag pattern the neighbouring routes use.frontend/server/internal/contentapi/— a typed client method.frontend/server/internal/render/templates/<page>.html.tmpl— embedded in the binary, so a template change needs a rebuild.frontend/server/internal/content/pages.go— the handler; register the route.- If the page should be crawlable, add it to the sitemap
(
backend/api/sitemap.pyand the/content/sitemappayload). - Copy that isn't per-city data goes in
frontend/content/pages.yaml, loaded fail-loud at boot.
Change something visual
- Read
frontend/DESIGN.mdfirst. It wins over any general design guidance. - Edit
frontend/static/style.css. Usevar(--token); never paste a hex. New surfaces take their colour from existing tokens so light mode keeps working for free. - If it's a grade colour, keep the scale's order and midpoints intact — they encode meaning, not decoration, and the charts pull from the same tokens as the legend.
- Verify at 390 / 800 / 1920 / 2560 / 3840px in both light and dark:
or drive a real browser with the Chrome DevTools MCP. Narrow while iterating:cd frontend && .venv/bin/python tools/shoot.pytools/shoot.py index --width 390 --scheme dark. - Watch the hard rules: inputs at
font-size: 16pxminimum (iOS zoom), ~44px touch targets, honourprefers-reduced-motion, metric order alwaysPrecip · High · Low.
Add or rotate a secret
sops edit infra/deploy/secrets/prod.yaml # never a plain editor
git commit && push && open a PR
Then it reaches the hosts by either route: an infra/** push to main fires
infra-sync.yml (re-renders prod's and beta's env files — /etc/thermograph.env
and /etc/thermograph-beta.env, both on vps2 — rolls nothing), or the next app
deploy renders it as step 2 of deploy.sh. Dev's vault (dev.yaml) is never
part of that sync — it's rendered only by dev's own deploy on vps1, and it
never layers common.yaml regardless.
Decide which file first:
- identical on prod and beta, and not a "lets one environment act as another"
credential →
common.yaml(never reaches dev, on vps1, at all); - per-environment, or one of
POSTGRES_PASSWORD/THERMOGRAPH_AUTH_SECRET/THERMOGRAPH_DATABASE_URL→prod.yaml/beta.yaml; - dev →
dev.yaml, and give it its own value. Never a copy of prod's.
Preview with infra/deploy/secrets/dry-run-render.sh. Audit cross-environment
drift with the key-gaps skill (names only, never values).
Never hand-edit /etc/thermograph.env, paste a plaintext secret onto a box,
or run seed-from-live.sh.
If a rotated value needs containers recreated to take effect, remember
infra-sync rolls nothing — you need an app deploy or a by-hand
SERVICE=all … deploy.sh.
Add a recurring background job
Ask one question first: does it need data?
- No (a timer, a connection, an external poke) → add a
cron.Jobinbackend/daemon/main.go. One replica, so no election needed. Give it an env var for its interval, defaulting sensibly and falling back to the default on a malformed value. - Yes → add an
/internal/*route inbackend/api/internal_routes.pyand have the daemon POST to it. Grading depends on polars and the cache; reimplementing it out-of-process would let results drift from the API's.
If you genuinely need an in-process background thread in the Python app instead,
read core/singleton.py first. Anything that issues upstream fetches on a
timer must run behind claim_leader() — N workers × M hosts multiplies quota
use N×M, and that has already caused overnight 429s in production. Things that
drain a per-worker request-fed queue (the neighbour warmer) correctly run
everywhere; so does the heartbeat, because a beat costs one local file append.
Then give it a heartbeat (audit.log_heartbeat()) so observability can alert on
its death — see the "too quiet to alert on" gap in
observability.
Change a Grafana alert or dashboard
Never through the UI. Provisioning overwrites it on the next start.
- Edit
observability/grafana/provisioning/alerting/*.ymlorobservability/grafana/dashboards/*.json. - If you're changing a threshold, re-derive it against real Loki data — the working for the current number is in the comments beside the rule.
- Open a PR.
observability-validate.ymlwill check that every condition names a real refId, every policy routes to a real receiver, and no literal webhook URL is committed. - Merging is not deploying. Ship it by hand per
observability/README.md— and rememberdocker restartwon't pick up a new environment variable. - Verify by forcing a real message into
#ops-alerts. The API redacts the webhook, so arrival is the only proof.
Debug: "the site is serving stale data"
Work the token chain, in this order:
GET /api/versionon the affected environment — ispayload_verwhat you expect? If a payload shape changed and this didn't move, that's your answer.- Is the derived-store token advancing? It's built from
PAYLOAD_VERplus the history end date (history_token), so a cell whose history stopped updating pins its token. - Is the history updating? Check
climate_syncfreshness (infra/ops/dbq.sh prod …) and whether upstream is in a rate-limit cooldown (the errors log, taggedretry/error). - Is it the frontend's TTL cache instead?
THERMOGRAPH_SSR_CACHE_TTLdefaults to 600s, in front of the backend's own caching. - Is it the browser?
cache.jsseeds IndexedDB from the/cellbundle. If ETags stopped being visible cross-origin (expose_headers), revalidation silently stops.
Debug: "a deploy went green but nothing changed"
Common causes, most likely first:
- The tree was already identical. A promotion between branches whose trees match merges nothing while still firing the deploy workflows.
- The domain wasn't touched.
deploy.ymlre-checks whether this push touched this domain, and skips the leg if not. - The tag is domain-keyed.
deployed_versionshowing a tag that isn't the branch head is correct behaviour, not drift. - Someone hand-edited
/opt/thermograph.deploy.shrunsgit reset --hardfirst; the edit is gone. - It's an observability change.
/opt/observabilitypoints at the archived repo and cannot pull. See above.
Promote to beta, and then to prod
dev → main your call, batched. Beta is the test environment;
not promoting is not testing.
main → release ALWAYS the owner's call. Prepare it, state what would ship,
the CI evidence and how long it soaked on beta, then recommend —
including recommending against.
Centralis's promote(from, to) does the mechanics and enforces the rule;
forge_pr_merge refuses main → release under any flag. Before promoting,
compare the two tips' trees, not their commit counts.
Roll back
Redeploying the previous image tag:
SERVICE=backend BACKEND_IMAGE_TAG=sha-<previous12hex> \
/opt/thermograph/infra/deploy/deploy.sh
Check the tag actually exists first. Both deploy scripts end by deleting
images outside the running pair. Beta now runs Swarm exactly like prod
(co-resident on vps2), so both usually keep a handful of stopped-task
references; dev, the one remaining compose environment (on vps1), is the one
that typically holds no local rollback target at all. rollback_to(dry_run: true) checks host and registry and will tell you when it's in neither.
Regenerate the bundled city data
backend/cities.json and cities_flavor.json are generated reference data, not
runtime state:
cd backend
python gen_cities.py # the curated city set
python gen_flavor.py # the editorial blurbs
Editorial copy has its own voice guide — the city-copy skill defines what
good reads like, and backfill-city-copy drives generation at scale. Both
enforce the relative-grades rule.
After a deploy, warm_cities.py pre-warms those cells so /climate pages serve
from cache and a crawl never bursts the archive quota. It's idempotent, paced,
and claims a flock — deploys launch it detached with no overlap check, so two
overlapping runs would otherwise double-spend quota and race the cache writes.
Work on a live host
Prefer Centralis: run_on_host, fleet_status, deployed_version,
logs_query, sql_query.
You may, without asking: read anything; restart or roll a wedged service; clear a full disk; kill a runaway process; bounce a crash-looping container; run the blessed deploy path onto an already-published image.
You must not: hand-edit the checkout to persist a change (git reset --hard
eats it); hand-edit /etc/thermograph.env (rendered artifact); force-push;
deploy prod from an unmerged branch; docker system prune -a on a live box (it
deletes your rollback image — docker image prune -f is the safe form); or
restart the _db service casually. Postgres/Timescale restarts and volume
operations get confirmed with the operator first.
Next: Traps and stale docs.