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.
9.4 KiB
2. Local setup
Every command in this document was run against this checkout and produced the output shown. If one fails for you, the difference is your machine, not the doc.
Toolchain
| Tool | Why | Notes |
|---|---|---|
| Python 3.12 | backend runtime and test suite | Must have the _sqlite3 extension. A pyenv 3.10 without it will fail at conftest import. scripts/test.sh looks for python3.12 (or uses uv) rather than whatever python3 is on PATH — deliberately. |
| Go 1.26 | frontend service and the backend daemon | Both are static CGO_ENABLED=0 builds. |
| Docker | images, the compose/Swarm stacks, smoke tests | Docker CLI ≥ 27 is fine. |
| uv (recommended) | fast venv creation | scripts/test.sh uses it when present. |
| shellcheck v0.11.0 | matches CI's pin exactly | Install to ~/.local/bin/shellcheck. A different version can invent new findings and fail CI on an unrelated push. |
| sops + age | reading/editing the secrets vault | Only needed if you touch infra/deploy/secrets/. |
| jq | the repo's .claude hooks use it |
Without it the prod-guard hook fails toward asking, which is safe but noisy. |
Check what you have:
which go python3.12 uv docker sops age jq shellcheck
go version && python3.12 --version && docker --version
Backend
Test suite (hermetic, no network, no Docker)
cd backend
make test # or: ./scripts/test.sh
make test ARGS='tests/data -q' # pass pytest args through
Verified: 429 passed, 8 skipped in 11.57s on a cold venv build.
The first run builds .venv-test/ on Python 3.12 and installs
requirements-dev.txt. tests/conftest.py is what makes the suite hermetic:
- no real Open-Meteo, Nominatim or GeoNames calls (the places index is marked as already-loaded so no background download starts);
- a throwaway SQLite accounts DB and derived store in
/tmp, never the repo'sdata/; - audit/error/access/activity/heartbeat log dirs redirected to
/tmp; - notifier and heartbeat threads disabled;
THERMOGRAPH_FRONTEND_BASE_INTERNALpointed at an unreachable placeholder (the app fails loud at import without it).
Boot it locally, no Docker, no Postgres
The backend falls back to SQLite when THERMOGRAPH_DATABASE_URL isn't a
Postgres URL, so this is enough:
cd backend
THERMOGRAPH_FRONTEND_BASE_INTERNAL=http://127.0.0.1:8080 \
THERMOGRAPH_BASE=/thermograph \
THERMOGRAPH_ENABLE_NOTIFIER=0 \
THERMOGRAPH_ENABLE_HEARTBEAT=0 \
.venv-test/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8137
Verified responses:
GET /healthz → {"status":"ok","role":"all"}
GET /thermograph/api/version → {"backend_version":"2","min_frontend":"1","payload_ver":"p2"}
app:app is a one-line re-export shim for web/app.py. Keep it — systemd, CI
and the container entrypoint all target that name.
Note the port: 8137 is the backend everywhere in this project (compose, Caddy, the smoke harness at 18137, the frontend's default internal base).
Image smoke test
cd backend
make smoke # builds the image, boots it + a throwaway TimescaleDB (tmpfs),
# asserts /healthz and /api/version
Uses docker-compose.test.yml on host port 18137 so it can't collide with a
dev server on 8137.
Frontend
The live frontend is Go. frontend/server/ is what builds, tests, ships
and runs. The Python files one level up (app.py, content.py,
api_client.py, format.py) are the superseded original — see
traps.
Test
cd frontend/server
go build ./... && go vet ./... && go test ./...
Verified: all seven packages ok (config, content, contentapi,
contentdata, format, handlers, render).
These same commands run inside frontend/Dockerfile's builder stage — plus a
gofmt -l check that must come back empty — so a failing Go test fails the
image build, which is how CI catches it. There is no separate frontend test
step in the workflows.
Run it
The process resolves static/ and content/ relative to its working
directory, so build in server/ and run from frontend/:
cd frontend/server && go build -o thermograph-frontend .
cd ..
THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \
THERMOGRAPH_BASE=/thermograph \
PORT=8080 \
./server/thermograph-frontend
Verified: GET /healthz → {"status":"ok"}, with a structured JSON log line
per request.
THERMOGRAPH_API_BASE_INTERNAL is required — boot fails loudly without it,
by design. Optional: THERMOGRAPH_BASE (default /thermograph; the image sets
/), THERMOGRAPH_API_VERSION (default v2 — only ever change it per the
API-version contract), THERMOGRAPH_API_BASE_PUBLIC,
THERMOGRAPH_SSR_CACHE_TTL (seconds, default 600), THERMOGRAPH_GOOGLE_VERIFY,
THERMOGRAPH_BING_VERIFY, PORT (default 8080).
Frontend against a real backend container
cd frontend
make backend-up # pulls + runs the published backend image + throwaway db
# on 127.0.0.1:18137, waits for /healthz, prints the URL
make backend-down
The image tag is derived from your checkout — sha-<12hex of git log -1 -- backend/>, the same domain-keyed rule build-push.yml and deploy.yml use —
so the harness follows the tree. If those backend commits are still local-only,
no image exists yet and the script says so; pin a published build with
THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex>.
make test-integration runs the Python integration tier against that. CI does
not run it — it needs a live backend container.
Heads-up: against a freshly-booted throwaway backend, that tier currently fails 7 of 16 with
503— the database is empty, so nothing is warm. Pre-existing, and unrelated to which image tag you use.
The daemon
cd backend/daemon
go build ./... && go vet ./... && go test ./...
THERMOGRAPH_INTERNAL_TOKEN=dev-token \
THERMOGRAPH_API_BASE_INTERNAL=http://localhost:8137 \
go run .
It refuses to start without THERMOGRAPH_INTERNAL_TOKEN — and the backend
answers 404 on the whole /internal/* surface when that token is unset. Both
ends fail closed. With Discord unconfigured it logs once and runs cron-only.
The full stack, locally
cd infra
make dev-up # docker-compose.yml + docker-compose.dev.yml overlay:
# uncapped CPU, backend published on 0.0.0.0:8137 for your LAN
make dev-down
This is a laptop convenience, not the hosted dev environment: the actual
dev environment now runs on vps1 with its own Postgres, deployed by CI, and
bound only to the WireGuard mesh (10.10.0.2:8137) — never 0.0.0.0, since
vps1 is a public box. See Infra and secrets. A local
make dev-up is fine on 0.0.0.0 because it's your own machine's LAN, not the
public internet.
The dev overlay exports COMPOSE_PROJECT_NAME=thermograph-dev so this stack
keeps volumes separate from anything else. Do not remove either half of the
project-name pinning — infra/docker-compose.yml pins name: thermograph,
and without it running compose from infra/ derives the project name infra,
silently creating a new stack beside the running one with fresh volumes.
Other infra/Makefile targets: up/down (pull + run the published images),
db-up/db-down (just Postgres, e.g. to run the app from a venv against it),
om-up/om-down/om-backfill (the self-hosted Open-Meteo overlay — the
backfill writes ~1–1.5 TB and takes hours).
Connectors (Centralis and friends)
The fleet is not reachable from a laptop off the WireGuard mesh, and Forgejo is mesh-only. Centralis is the control plane that fronts all of it — the app database, the ERA5 lake, fleet logs, Grafana, Forgejo, docs and notes, and Discord.
claude mcp add --transport http centralis https://mcp.thermograph.org/mcp \
--header "Authorization: Bearer $CENTRALIS_TOKEN"
Ask the operator for a token; don't share it. Verify with "what's running on
prod right now?" — it should call fleet_status and list the Swarm services.
Also worth installing locally: Chrome DevTools MCP (design verification
needs a real browser on your machine) and Figma. Grafana's official MCP
server is optional and read-only — but remember dashboards are provisioned from
repo JSON, so a durable change is still a PR via dashboard_write.
Run mcp__centralis__onboarding for the current, authoritative connector list;
it will be fresher than this page.
Repo-local guardrails
.claude/settings.json wires three hooks that travel with the checkout:
| Hook | When | What |
|---|---|---|
prod-guard.sh |
before Bash / live-host MCP calls | Classifies by allowlist: only positively-recognised read-only commands pass; everything else asks. vps1 is guarded as strictly as vps2 — it hosts Forgejo, Grafana and the mesh-only dev environment, so a destructive command there takes out git, CI and the registry at once, not just a dev sandbox. |
secrets-guard.sh |
before Write/Edit | Denies any direct write to infra/deploy/secrets/*.yaml. Use sops edit. |
lint-after-edit.sh |
after Write/Edit | shellchecks an edited *.sh and feeds findings straight back. Exits quietly if shellcheck is missing. |
They enforce what CLAUDE.md can only ask for. If you change prod-guard.sh's
classifier, re-read .claude/hooks/README.md first — it documents a real
silent-total-bypass failure mode in the parsing loop.
Next: Repo map.