thermograph/infra/terraform
Emi Griffith 9c0f493d68
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
Log hygiene: Alloy CPU, Loki chunks/limits, Caddy field-stripping
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.
2026-07-23 21:34:06 -07:00
..
modules Log hygiene: Alloy CPU, Loki chunks/limits, Caddy field-stripping 2026-07-23 21:34:06 -07:00
.gitignore Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
.terraform.lock.hcl Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
main.tf Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
outputs.tf Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
README.md Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
terraform.tfvars.example Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
variables.tf Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
versions.tf Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00

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 ufw firewall (22/80/443 always; on a host with no domain it also opens the app port 8137);
  • ensures the git checkout at app_dir exists (clones this repo on a fresh box) and resets it to the host's git_branch;
  • renders /etc/thermograph-topology.env from Terraform variables (non-secret sizing only — WORKERS/APP_CPUS/DB_CPUS/DB_MEMORY/etc.), then runs deploy/render-secrets.sh (from that same freshly-synced checkout) to render /etc/thermograph.env from 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_TAG for the compose file): docker login to 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 agent account, ~/.ssh/thermograph_agent_ed25519 — see deploy/provision-agent-access.sh). Point ssh_private_key_path at that key (~ is expanded).
  • The hosts are Debian/Ubuntu with apt and outbound internet (Docker/Caddy installs pull from the network). Docker may already be present — installs are conditional.
  • For the prod host: DNS for thermograph.org must 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 binds 127.0.0.1:8137 (loopback), so opening the port in ufw alone does not expose it. Reach beta via an SSH tunnel, or set domain = "beta.thermograph.org" (adds Caddy TLS) — or add the 0.0.0.0-publishing dev overlay to compose_files — to make it reachable. COOKIE_SECURE is auto-set to 0 when there's no domain (a Secure cookie is never sent over plain HTTP) and 1 behind Caddy TLS.
  • The rendered Caddyfile only reverse-proxies the app. The repo's deploy/Caddyfile additionally serves the emigriffith.dev portfolio 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, compose up reconciles).