|
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 6s
PR build (required check) / validate-observability (pull_request) Successful in 23s
PR build (required check) / gate (pull_request) Successful in 2s
thermograph-observability (and thermograph-backend/frontend/infra) are archived on Forgejo now that the monorepo cutover has landed, so this domain's work lands here in observability/ instead of a standalone repo PR. Alloy (observability/alloy/config.alloy), deployed from /opt/observability on prod and beta (confirmed via docker inspect mounts on the live hosts; the monorepo's own observability/ copy isn't what's running yet -- that's a separate, not-yet-done cutover step for this domain, flagged below): - Raise loki.source.file's file_watch poll interval (caddy + app_jsonl) from the 250ms/250ms PollingFileWatcher default to 2s/10s, and local.file_match's glob-rescan sync_period from 10s to 1m. - Raise discovery.docker's refresh_interval from 60s to 5m so Swarm task churn (~every 90s) stops restarting tailers on every refresh. - Drop noise/duplicate containers (alloy, autoscaler, thermograph-test_*, thermograph-lb, thermograph_worker) via a relabel rule; the worker's stdout is pure /healthz noise, already superseded by its own access/*.jsonl. - Fix loki.source.docker double-relabeling: it took both the already- relabelled target output AND relabel_rules, running every rule twice per entry and making the new drop rules a no-op on the second pass. Pass raw discovery targets + relabel_rules instead. - Add a loki.process stage for Caddy logs that drops well-known crawler/bot lines (semrushbot, claudebot, ahrefsbot, yandexbot, bytespider, mj12bot, petalbot) -- Caddy's own log_skip needs >= 2.7 and both hosts are older (beta confirmed 2.6.2; prod is actually 2.11.4, a version-skew worth addressing separately), so this is the shipper-side equivalent. Loki (observability/loki/config.yml): 30 low-rate streams were hitting the 30m chunk_idle_period long before chunk_target_size, flushing chunks 1.98% full on average. Raise chunk_idle_period to 2h, max_chunk_age to 12h, set chunk_target_size/chunk_encoding explicitly. Add explicit ingestion/stream limits sized for a three-node hobby fleet -- their absence was the source of the sporadic 429s (Loki was falling back to its stricter multi-tenant defaults). CI (.forgejo/workflows/observability-validate.yml): the Alloy config had no syntax coverage. Download the pinned v1.9.1 alloy binary (matches observability/alloy/docker-compose.agent.yml) straight from its GitHub release and run `alloy validate` (minus the one line covering a known gap where validate doesn't recognize the top-level livedebugging singleton block, unlike alloy run). Caddy (infra/terraform/modules/thermograph-host/templates/Caddyfile.tftpl, the true source for /etc/caddy/Caddyfile -- confirmed live on prod, whose deployed file's own header says "RENDERED BY TERRAFORM"; infra/deploy/Caddyfile is the documented manual-apply fallback, updated in step to avoid drift): - health_interval 5s -> 15s: a quarter of the active-healthcheck polling load for the same restart-safety guarantee. - A `filter` format log encoder deleting request>headers, request>tls, and resp_headers (the full-header/TLS-block serialization was the main driver of ~1,133B/line on prod, ~922B on beta), plus roll_size/roll_keep so the file itself doesn't grow unbounded between logrotate runs. - Strip the query string entirely from logged request URIs via a `regexp` field filter -- Caddy's default logger recorded request.uri *including* the query string, so every `?q=<search text>` sat in Loki next to the client IP for the full 30-day retention. Confirmed empirically (via `caddy validate` and a scratch `caddy run` against beta's real 2.6.2 binary) that keeping request>headers>User-Agent alongside a parent-level header delete isn't achievable in this Caddy version -- deleting the parent drops the whole subtree regardless of a child override, and `log_append` (which would let it be hoisted out first) needs a newer Caddy than beta runs. Traded away rather than block on it. Deferred (host-level, not in any repo, applied directly and separately): /etc/docker/daemon.json (10m/3-file json-file log caps) on both hosts -- config written, but NOT restarting dockerd on either: prod is the fleet's sole Swarm manager with Live Restore disabled, so a daemon restart there would both interrupt Swarm's control plane and actually stop/restart every container on the host (no live-restore to keep them up), a real production outage window, not a blip. Left for a planned maintenance window instead. A 14-day retention cron for the app's applogs JSONL volume is applied on both hosts (/etc/cron.d/thermograph-applogs-retention) -- plain find+delete rather than logrotate, since the app already self-dates one file per day per category and logrotate's rotate-in-place model doesn't fit files it doesn't own the naming of. Grafana itself (docker-compose.yml, grafana/, caddy-grafana.conf, dashboard.thermograph.org's Caddy block) is untouched by any of this. |
||
|---|---|---|
| .. | ||
| modules | ||
| .gitignore | ||
| .terraform.lock.hcl | ||
| main.tf | ||
| outputs.tf | ||
| README.md | ||
| terraform.tfvars.example | ||
| variables.tf | ||
| versions.tf | ||
Thermograph — Terraform (host provisioning)
Terraform that provisions and configures VPS hosts and hands the app off to
docker compose, which pulls the published app image (built + pushed by the app
repo's CI) and runs it — no app source is ever built or checked out on a host.
By default it does not create servers: modules/thermograph-host is
SSH-provisioner-driven against a host that already exists. An optional
modules/gcp-host can additionally create the VM on GCP first (see "GCP
scaffold" below) — scaffold-only today, no live resources until you opt in.
What it manages
One reusable module (modules/thermograph-host) is instantiated per host via
for_each (main.tf's local.all_hosts, merging var.hosts — SSH-managed,
already-existing boxes — with any var.gcp_hosts Terraform created itself).
This config manages two VPS hosts today:
| key | role | VPS | branch | domain | notes |
|---|---|---|---|---|---|
prod |
prod | NEW 48 GB / 12-core box (169.58.46.181) |
release (of the APP repos — see backend_image_tag / frontend_image_tag) |
thermograph.org |
Caddy TLS; sized up (8/8/4/16g) |
beta |
beta | old box 75.119.132.91 |
main (of the APP repo) |
beta.thermograph.org |
Caddy TLS; also hosts Forgejo |
Each host's own checkout on disk (app_dir, git_branch) is this infra
repo, not the app repo — the "branch" column above is which app-repo tag a
host is meant to track conceptually; the actual pinned versions are
var.hosts[*].backend_image_tag / frontend_image_tag (e.g. "sha-<12 hex>" each —
the app is two separately-published images, emi/thermograph-backend/app and
emi/thermograph-frontend/app), since the host has no app checkout to derive a tag
from. The LAN dev server is out of scope here — it
builds from source via the app repo's deploy/deploy-dev.sh (a self-hosted
Forgejo Actions runner), not Terraform.
Per host, over SSH provisioners, Terraform:
- installs Docker + the compose plugin if missing;
- configures a
ufwfirewall (22/80/443 always; on a host with no domain it also opens the app port8137); - ensures the git checkout at
app_direxists (clones this repo on a fresh box) and resets it to the host'sgit_branch; - renders
/etc/thermograph-topology.envfrom Terraform variables (non-secret sizing only —WORKERS/APP_CPUS/DB_CPUS/DB_MEMORY/etc.), then runsdeploy/render-secrets.sh(from that same freshly-synced checkout) to render/etc/thermograph.envfrom the SOPS+age vault — Terraform itself never sees or carries an app secret (see "Secrets" below); - for a host with a domain, installs a rendered Caddyfile and reloads Caddy;
- brings the stack up on the explicit per-service tags (exports
BACKEND_IMAGE_TAG/FRONTEND_IMAGE_TAGfor the compose file):docker loginto the registry,docker compose <-f each compose file> pull backend frontend, then... up -d --remove-orphans, running docker as root with both env files sourced; - health-checks
http://127.0.0.1:8137/.
A change to the rendered topology env, the compose files, the branch, the app image
tag, or the sizing flips the null_resource trigger and re-runs the provisioners on
next apply.
GCP scaffold (no live resources)
modules/gcp-host can create a GCE VM (+ a dedicated VPC, subnet, and a firewall
opening 22/80/443) instead of assuming the host already exists — see main.tf's
module.gcp_vm. It contributes nothing to provisioning: its only output
(external_ip) feeds into the exact same thermograph-host module every
SSH-managed host uses, so there's one provisioning path regardless of how a
host came to exist. var.gcp_hosts defaults to {}, so by default no
google_* resource is ever planned and the google provider is never
invoked — terraform plan/validate succeed with no GCP credentials
configured at all. To actually use it: populate an entry in
terraform.tfvars (see the commented example in
terraform.tfvars.example) and authenticate via gcloud auth application-default login or GOOGLE_APPLICATION_CREDENTIALS. This is the
same create-then-provision composition a future Proxmox module (the
architecture doc's longer-term target — see §6/§9 there) would use.
Container sizing is env-driven
docker-compose.yml reads WORKERS, APP_CPUS, DB_CPUS, and DB_MEMORY from the
environment (defaults 4 / 4 / 2 / 8g, identical to before). Terraform sets them per
host through /etc/thermograph-topology.env, so the big prod box can run larger caps
without a compose edit. The Postgres internal memory budget (shared_buffers,
effective_cache_size, work_mem, maintenance_work_mem) is derived from the same
DB_MEMORY by deploy/db/init/20-tuning.sh — so raising db_memory scales the
container cap and the tuning together (prod 16g → shared_buffers 4 GB). The tuning
applies on a fresh DB volume; on an existing volume re-run it by hand (see the script
header).
Prerequisites
- Terraform >= 1.6 (v1.15 is installed).
- SSH key access to both hosts as a sudo-capable user (the live boxes use the
dedicated
agentaccount,~/.ssh/thermograph_agent_ed25519— seedeploy/provision-agent-access.sh). Pointssh_private_key_pathat that key (~is expanded). - The hosts are Debian/Ubuntu with
aptand outbound internet (Docker/Caddy installs pull from the network). Docker may already be present — installs are conditional. - For the
prodhost: DNS forthermograph.orgmust point at the new box before apply, or Caddy's first-request cert issuance will fail.
Use
cd terraform
cp terraform.tfvars.example terraform.tfvars # then edit: real IPs + secrets
terraform init
terraform plan
terraform apply
Target one host with -target='module.host["beta"]' if you want to apply to just one.
Secrets
App secrets (POSTGRES_PASSWORD, THERMOGRAPH_AUTH_SECRET, VAPID keys,
REGISTRY_TOKEN, Discord/SMTP credentials, …) are not Terraform variables
— they live in the git-native SOPS+age vault at ../deploy/secrets/*.yaml
(committed, encrypted) and are rendered into /etc/thermograph.env at deploy
time by ../deploy/render-secrets.sh, which the provisioner's deploy step
runs from the freshly-synced checkout. Terraform only renders the non-secret
/etc/thermograph-topology.env (sizing/routing). See
../deploy/secrets/README.md to rotate a secret or add a new one — it's a
sops edit + commit + deploy, no Terraform apply involved.
om_rclone_conf (object-storage bucket credentials for the self-hosted ERA5
archive) is the one exception still supplied via Terraform, in
terraform.tfvars — folding it into the vault too is a reasonable future
step, not done here.
Local state
The backend is local: terraform.tfstate is written next to the config.
It's gitignored (terraform/.gitignore and the root .gitignore) — keep it
off shared disks and back it up somewhere private. terraform.tfvars is
likewise gitignored (it may carry a repo_url credential and always carries
om_rclone_conf); only terraform.tfvars.example (dummy values) is
committed. .terraform.lock.hcl is committed so provider versions are
pinned across machines.
WARNING — applying against live prod
terraform apply runs remote-exec on the server: it resets the checkout to the
branch, renders topology config + secrets, and runs docker compose pull && up -d
(pulling the pinned backend_image_tag / frontend_image_tag and recreating
containers — a brief app restart).
Against the live production host this is a real deploy. Review the plan, apply in a
maintenance window, and prefer -target to touch one host at a time.
This is separate from the Postgres data cutover in deploy/POSTGRES-MIGRATION.md.
Terraform provisions the host and starts the stack; it does not migrate the
SQLite→Postgres accounts data. Sequence them deliberately: for a first cutover on a
host, follow the migration doc's freeze/backup/copy steps around the point where
Terraform brings the stack up — don't let Terraform recreate containers mid-migration.
Assumptions / notes
- beta has no public domain by default. With
compose_files = ["docker-compose.yml"]the app binds127.0.0.1:8137(loopback), so opening the port inufwalone does not expose it. Reach beta via an SSH tunnel, or setdomain = "beta.thermograph.org"(adds Caddy TLS) — or add the0.0.0.0-publishing dev overlay tocompose_files— to make it reachable.COOKIE_SECUREis auto-set to0when there's no domain (a Secure cookie is never sent over plain HTTP) and1behind Caddy TLS. - The rendered Caddyfile only reverse-proxies the app. The repo's
deploy/Caddyfileadditionally serves theemigriffith.devportfolio and legacy redirects; those are host-specific and not templated here. - Provisioner-based by design: the hosts already exist, so this is not a
create-from-scratch cloud config. Re-applying is idempotent (installs are guarded,
git reset --hard, composeupreconciles).