Replace the per-cell parquet cache with TimescaleDB hypertables as the production backend for the raw daily climate record, and drop pg_duckdb. Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a Postgres URL (dev, tests, offline tooling), the same dialect switch the accounts DB and derived store already use, so CI stays Postgres-free. - data/climate_store.py: psycopg + polars bridge over climate_history (a hypertable), climate_recent, and climate_sync (per-cell freshness). Reads via pl.read_database, writes via COPY + ON CONFLICT upsert; fail-soft to a cache miss so a DB hiccup degrades to upstream refetch. - data/climate.py: route every cache/mtime touchpoint through a backend dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the stale-serve path still avoids bumping it, so derived-payload tokens invalidate on exactly the same events as before. - alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema (compression policy on year-old chunks), guarded to no-op off Postgres. - migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the parquet cache into the hypertables, preserving file mtimes as sync timestamps so recent_stamp is unchanged across cutover. - db image -> stock timescale/timescaledb:latest-pg18; drop the custom pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb tuning GUC. Docs updated for the new backend and cutover. Co-authored-by: Claude <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| modules/thermograph-host | ||
| .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 the existing VPS hosts and hands the app
off to docker compose. It does not create servers (no cloud provider) and does not
replace compose — it prepares each host (Docker, firewall, checkout, secrets, Caddy)
and runs docker compose up.
What it manages
One reusable module (modules/thermograph-host) is instantiated per host via
for_each. This config manages two VPS hosts:
| key | role | VPS | branch | domain | notes |
|---|---|---|---|---|---|
prod |
prod | NEW 48 GB / 12-core box | release |
thermograph.org |
Caddy TLS; sized up (8/4/16g) |
beta |
beta | old box 75.119.132.91 |
main |
(none) | testing/beta; no public domain yet |
The dev branch is out of scope here — it deploys to the LAN dev server via
deploy/deploy-dev.sh (a self-hosted GitHub 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 on a fresh box) and resets it to the host's branch; - renders
/etc/thermograph.envfrom Terraform variables (secrets injected from tfvars, pushed via provisionercontentso they never touch local disk) and installs it root-owned0640; - for a host with a domain, installs a rendered Caddyfile and reloads Caddy;
- brings the stack up:
docker compose <-f each compose file> up -d --build, running docker as root with/etc/thermograph.envsourced in the same shell; - health-checks
http://127.0.0.1:8137/.
A change to the rendered env, the compose files, the branch, or the sizing flips the
null_resource trigger and re-runs the provisioners on next apply.
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.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 (default
deploy). 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.
Local state + secrets caveat
The backend is local: terraform.tfstate is written next to the config and holds
every secret in cleartext (the rendered env, VAPID keys, DB password, …). It is
gitignored (terraform/.gitignore and the root .gitignore). Keep it off shared disks
and back it up somewhere private. terraform.tfvars is likewise gitignored; 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, rewrites /etc/thermograph.env, and runs docker compose up -d --build
(rebuilding images 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).