Add Terraform to provision the VPS hosts (compose keeps running the app) (#223)
Terraform config under terraform/ manages the two existing VPS hosts and hands the
app to docker-compose, with local state:
- prod: the new 48GB/12-core VPS (release branch, thermograph.org), sized larger.
- beta: the old VPS 75.119.132.91 (main branch, testing tier), no public domain.
- The LAN dev box stays on deploy/deploy-dev.sh (dev branch) — out of Terraform.
A reusable module (modules/thermograph-host) SSHes each host to install docker/
compose/ufw (+ Caddy when a domain is set), sync the checkout to the host's branch,
render /etc/thermograph.env from Terraform variables (secrets pushed via provisioner
content, never on local disk), `docker compose up -d`, and health-check. Named
volumes are preserved on re-apply, so the Postgres data is never recreated.
Container resources are now env-driven in docker-compose.yml (APP_CPUS/DB_CPUS/
DB_MEMORY/WORKERS) with unchanged defaults, so Terraform can size each host.
2026-07-20 07:42:15 +00:00
|
|
|
# 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 `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 on a fresh box) and resets it to
|
|
|
|
|
the host's branch;
|
|
|
|
|
- renders `/etc/thermograph.env` from Terraform variables (secrets injected from tfvars,
|
|
|
|
|
pushed via provisioner `content` so they never touch local disk) and installs it
|
|
|
|
|
root-owned `0640`;
|
|
|
|
|
- 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.env` sourced 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
|
2026-07-20 14:33:09 +00:00
|
|
|
compose edit. The Postgres *internal* memory budget (`shared_buffers`,
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
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>
2026-07-20 20:15:55 +00:00
|
|
|
`effective_cache_size`, `work_mem`, `maintenance_work_mem`) is derived from the same
|
2026-07-20 14:33:09 +00:00
|
|
|
`DB_MEMORY` by `deploy/db/init/20-tuning.sh` — so raising `db_memory` scales the
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
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>
2026-07-20 20:15:55 +00:00
|
|
|
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).
|
Add Terraform to provision the VPS hosts (compose keeps running the app) (#223)
Terraform config under terraform/ manages the two existing VPS hosts and hands the
app to docker-compose, with local state:
- prod: the new 48GB/12-core VPS (release branch, thermograph.org), sized larger.
- beta: the old VPS 75.119.132.91 (main branch, testing tier), no public domain.
- The LAN dev box stays on deploy/deploy-dev.sh (dev branch) — out of Terraform.
A reusable module (modules/thermograph-host) SSHes each host to install docker/
compose/ufw (+ Caddy when a domain is set), sync the checkout to the host's branch,
render /etc/thermograph.env from Terraform variables (secrets pushed via provisioner
content, never on local disk), `docker compose up -d`, and health-check. Named
volumes are preserved on re-apply, so the Postgres data is never recreated.
Container resources are now env-driven in docker-compose.yml (APP_CPUS/DB_CPUS/
DB_MEMORY/WORKERS) with unchanged defaults, so Terraform can size each host.
2026-07-20 07:42:15 +00:00
|
|
|
|
|
|
|
|
## Prerequisites
|
|
|
|
|
|
|
|
|
|
- Terraform >= 1.6 (v1.15 is installed).
|
|
|
|
|
- SSH key access to **both** hosts as a **sudo-capable** user (default `deploy`). 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
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
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 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).
|