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.
This commit is contained in:
Emi Griffith 2026-07-20 00:42:15 -07:00 committed by GitHub
parent 2e1c746b7c
commit 47aea569ed
14 changed files with 1056 additions and 7 deletions

View file

@ -41,17 +41,19 @@ services:
# effective_cache_size 6GB, work_mem/maintenance_work_mem, plus duckdb.max_memory # effective_cache_size 6GB, work_mem/maintenance_work_mem, plus duckdb.max_memory
# 4GB for parquet queries). shm_size backs parallel-query shared memory (the 64MB # 4GB for parquet queries). shm_size backs parallel-query shared memory (the 64MB
# docker default is too small once shared_buffers/parallelism grow). # docker default is too small once shared_buffers/parallelism grow).
mem_limit: 8g # Sized via env (Terraform sets DB_CPUS/DB_MEMORY per host); defaults match the
# historical 2 CPU / 8 GB budget so a plain `docker compose up` is unchanged.
mem_limit: ${DB_MEMORY:-8g}
shm_size: 1gb shm_size: 1gb
# Cap the DB at 2 CPUs. Compose v2 honors the top-level `cpus:`; the # Cap the DB at DB_CPUS CPUs. Compose v2 honors the top-level `cpus:`; the
# deploy.resources block is the Swarm-style equivalent, kept for parity. # deploy.resources block is the Swarm-style equivalent, kept for parity.
cpus: 2.0 cpus: ${DB_CPUS:-2}
deploy: deploy:
resources: resources:
limits: limits:
cpus: "2.0" cpus: "${DB_CPUS:-2}"
# Must match mem_limit above (compose rejects distinct values). # Must match mem_limit above (compose rejects distinct values).
memory: 8G memory: ${DB_MEMORY:-8g}
restart: unless-stopped restart: unless-stopped
# No host port on purpose: the app reaches Postgres as db:5432 on the # No host port on purpose: the app reaches Postgres as db:5432 on the
# compose network. Nothing outside the stack should touch the database. # compose network. Nothing outside the stack should touch the database.
@ -67,7 +69,9 @@ services:
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph
THERMOGRAPH_BASE: / THERMOGRAPH_BASE: /
PORT: 8137 PORT: 8137
WORKERS: 4 # Worker count is env-driven so Terraform can raise it on a bigger host;
# defaults to 4 to keep a plain `docker compose up` identical to before.
WORKERS: ${WORKERS:-4}
# One worker wins this lock and runs the subscription notifier / homepage # One worker wins this lock and runs the subscription notifier / homepage
# sweep; it lives on the appdata volume so it's shared across workers. # sweep; it lives on the appdata volume so it's shared across workers.
THERMOGRAPH_SINGLETON_LOCK: /app/data/notifier.lock THERMOGRAPH_SINGLETON_LOCK: /app/data/notifier.lock
@ -83,7 +87,14 @@ services:
# Parquet cache, notifier.lock, homepage.json, vapid.json persist here. # Parquet cache, notifier.lock, homepage.json, vapid.json persist here.
- appdata:/app/data - appdata:/app/data
- applogs:/app/logs - applogs:/app/logs
cpus: 4.0 # Sized via env (Terraform sets APP_CPUS per host); defaults to 4 CPUs. The
# deploy.resources block mirrors the top-level `cpus:` for Swarm parity; the
# dev overlay drops both so dev runs uncapped.
cpus: ${APP_CPUS:-4}
deploy:
resources:
limits:
cpus: "${APP_CPUS:-4}"
ports: ports:
# Loopback only — host Caddy terminates TLS and reverse-proxies to this. # Loopback only — host Caddy terminates TLS and reverse-proxies to this.
- "127.0.0.1:8137:8137" - "127.0.0.1:8137:8137"

15
terraform/.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
# Terraform working dir + plugins
.terraform/
# Local state (holds secrets — never commit)
*.tfstate
*.tfstate.*
crash.log
crash.*.log
# tfvars carry real secrets — ignore all except the checked-in example
*.tfvars
!*.tfvars.example
# The dependency lock IS committed (pins provider versions across machines) — keep it.
!.terraform.lock.hcl

View file

@ -0,0 +1,65 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/local" {
version = "2.9.0"
constraints = "~> 2.5"
hashes = [
"h1:9rBZCMNpxKwMlRbWH2QpwD3kqUCAejdOZQ/aiiDObXQ=",
"zh:0baa4566cf77f1ff52f4293d1c8536202dd23edc197c3196413a28343c3ac3a0",
"zh:16b5559c3c07088ddad11a9bb9e9c0799999363c2958e9a5be2bcbbf2cd9ca64",
"zh:197c79015a10d1cce904a8ea722cbc750c42aeae2da53f44a6a0751d9fd1aa90",
"zh:29d0b03e5343a80677ebfeb2e2c31cbe4b1f65e736e53417454a4277fec2544c",
"zh:4896bfa6cf1d2fd562b47ef2e87f47862ae92a04f8ad5d764380f0c6653473b8",
"zh:531f8529cbca49f681883e57761a05a8398afaef6d1ab0d205d26bf12f4428e8",
"zh:6aaf5011d83161c86d2bfb80c0923ec934e578288758da2f37acb7aec129004b",
"zh:7430275253d3d3c40aa6179e0ec0d63212874dbbc06c5a51b9d07ec590f9756c",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:be17dc611e95e26cdf6cad79dfccf1064f0e32032a2efeb939a9bbe7fb1cbfe9",
"zh:f0e3b0aa644202e1d79d2000dca91f6019425da71e9800fa23f27e51c034f195",
"zh:f62bae4519e4ead49182ddc8afe8cf61e2a4c3ba3973b0fbba967736a2696aa3",
"zh:fcafa360a5b0b96244f26f4e3a6d642b716a376557142c2442ff2fb12d11da18",
]
}
provider "registry.terraform.io/hashicorp/null" {
version = "3.3.0"
constraints = "~> 3.2"
hashes = [
"h1:l+dm3lhmu4ys7GbvIldfn544olSPH0DOiYruuFSfQkY=",
"zh:021748b5ea3b5f6956f2e75c42c5cdc113b391fb98ac71364a4965d23b37000f",
"zh:3b27956f8541d46704fda234e0d535c2ae2a4b33411848b1ee262a1ec03568b0",
"zh:3de4ed47d6d0f4d8edba4a5092c7c9799950eda63989d8d0d2586e6afcb0aa20",
"zh:57ed8935c7d56dbc91cf2673534582cacfaab7a2f105f51d9f797e99df0c0c47",
"zh:58e176ba1d142827089e30e0711e007309a9f2726e8881986da5026e9778fdf4",
"zh:5949c4a3d4a93f841f155cdb7e991c087e637145c1630572e21948224f8f4923",
"zh:76d60f366b743003c1b085afa769b45b2198ee919927e45807d7d44fb42c067d",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:79cd1bab1261a07f84e917191d7ddc4340ac5f5524283767256f7ffd7f87caf0",
"zh:8ec9083038cf710b30e319eaa467c9df7fa52bbd9969b61053a35bc2cdd2e0a6",
"zh:a6e502cb579685ab7aeb886c2bb11ddd9cfed74b41008592d57cbc3351a9218b",
"zh:acb74d6b4f66ff6acfcda315df802a7432170ef3955c9b432cb4580767004006",
"zh:f0ce55d8d9ffdb33dab612b1246f9bab060a9d54fc32ce2b4a038646155660af",
]
}
provider "registry.terraform.io/hashicorp/random" {
version = "3.9.0"
constraints = "~> 3.6"
hashes = [
"h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=",
"zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1",
"zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea",
"zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f",
"zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0",
"zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61",
"zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc",
"zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e",
"zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef",
"zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b",
"zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257",
"zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04",
]
}

107
terraform/README.md Normal file
View file

@ -0,0 +1,107 @@
# 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
compose edit. **Note:** this only sizes the *containers*. The Postgres *internal* memory
budget (`shared_buffers`, `effective_cache_size`, `work_mem`, `duckdb.max_memory`) lives
in `deploy/db/init/20-tuning.sql` and must be raised **separately** to exploit the 48 GB
prod box — those settings apply on a fresh DB volume and are not driven by Terraform.
## 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).

55
terraform/main.tf Normal file
View file

@ -0,0 +1,55 @@
locals {
# Repo root (one level above this terraform/ dir). The module hashes the compose
# files here so a compose change re-triggers the remote deploy, and this is the
# tree the host's checkout mirrors over git.
repo_root = abspath("${path.root}/..")
}
# One module instance per host. The module is entirely SSH-provisioner driven it
# configures an already-existing VPS and hands the app off to docker compose.
module "host" {
source = "./modules/thermograph-host"
for_each = var.hosts
# Per-host config
name = each.key
host = each.value.host
ssh_user = each.value.ssh_user
ssh_private_key_path = each.value.ssh_private_key_path
role = each.value.role
git_branch = each.value.git_branch
domain = each.value.domain
compose_files = each.value.compose_files
app_dir = each.value.app_dir
workers = each.value.workers
app_cpus = each.value.app_cpus
db_cpus = each.value.db_cpus
db_memory = each.value.db_memory
# Shared infra config
repo_root = local.repo_root
repo_url = var.repo_url
app_port = var.app_port
# Shared secrets -> /etc/thermograph.env
postgres_password = var.postgres_password
auth_secret = var.auth_secret
vapid_private_key = var.vapid_private_key
vapid_public_key = var.vapid_public_key
vapid_contact = var.vapid_contact
google_verify = var.google_verify
bing_verify = var.bing_verify
mail_backend = var.mail_backend
smtp_host = var.smtp_host
smtp_port = var.smtp_port
smtp_user = var.smtp_user
smtp_password = var.smtp_password
smtp_starttls = var.smtp_starttls
mail_from = var.mail_from
mail_reply_to = var.mail_reply_to
discord_webhook = var.discord_webhook
discord_public_key = var.discord_public_key
discord_app_id = var.discord_app_id
discord_bot_token = var.discord_bot_token
discord_client_secret = var.discord_client_secret
}

View file

@ -0,0 +1,200 @@
locals {
# THERMOGRAPH_DATABASE_URL is built from the same password compose uses for the db
# container, so the app always matches the database it initialized.
database_url = "postgresql+asyncpg://thermograph:${var.postgres_password}@db:5432/thermograph"
# A Secure cookie is only sent over HTTPS, so enable it only where Caddy terminates
# TLS (domain set). A plain-HTTP dev box (domain "") would otherwise drop its login
# cookie and no one could stay signed in.
cookie_secure = var.domain != "" ? "1" : "0"
# Public base URL: the domain over HTTPS, else the raw host:port for a Caddy-less box.
base_url = var.domain != "" ? "https://${var.domain}" : "http://${var.host}:${var.app_port}"
# `-f a -f b` for the compose invocations (dev layers the dev overlay).
compose_flags = join(" ", [for f in var.compose_files : "-f ${f}"])
# Hash the local compose files so a compose edit re-triggers the remote deploy.
compose_files_sha = join(",", [for f in var.compose_files : filesha256("${var.repo_root}/${f}")])
# Rendered /etc/thermograph.env (sensitive carries every secret).
env_content = templatefile("${path.module}/templates/thermograph.env.tftpl", {
app_port = var.app_port
postgres_password = var.postgres_password
database_url = local.database_url
auth_secret = var.auth_secret
workers = var.workers
app_cpus = var.app_cpus
db_cpus = var.db_cpus
db_memory = var.db_memory
base = "/"
base_url = local.base_url
cookie_secure = local.cookie_secure
vapid_private_key = var.vapid_private_key
vapid_public_key = var.vapid_public_key
vapid_contact = var.vapid_contact
google_verify = var.google_verify
bing_verify = var.bing_verify
mail_backend = var.mail_backend
smtp_host = var.smtp_host
smtp_port = var.smtp_port
smtp_user = var.smtp_user
smtp_password = var.smtp_password
smtp_starttls = var.smtp_starttls
mail_from = var.mail_from
mail_reply_to = var.mail_reply_to
discord_webhook = var.discord_webhook
discord_public_key = var.discord_public_key
discord_app_id = var.discord_app_id
discord_bot_token = var.discord_bot_token
discord_client_secret = var.discord_client_secret
})
# Caddyfile is only meaningful on a host with a public domain.
caddy_content = var.domain != "" ? templatefile("${path.module}/templates/Caddyfile.tftpl", {
domain = var.domain
port = var.app_port
}) : "# No public domain on this host; Caddy is not managed here.\n"
}
resource "null_resource" "host" {
# Re-provision when the rendered env, the compose files, the branch, sizing, or the
# Caddyfile change. sha256 of the secret-bearing env is unwrapped with nonsensitive()
# (a one-way hash leaks nothing) so plans stay readable.
triggers = {
env_sha = nonsensitive(sha256(local.env_content))
compose_sha = local.compose_files_sha
compose_flags = local.compose_flags
caddy_sha = sha256(local.caddy_content)
branch = var.git_branch
app_dir = var.app_dir
sizing = "${var.workers}/${var.app_cpus}/${var.db_cpus}/${var.db_memory}"
}
connection {
type = "ssh"
host = var.host
user = var.ssh_user
private_key = file(pathexpand(var.ssh_private_key_path))
timeout = "5m"
}
# Push the rendered secrets via `content` so they are never written to local disk.
provisioner "file" {
content = local.env_content
destination = "/tmp/thermograph.env"
}
provisioner "file" {
content = local.caddy_content
destination = "/tmp/thermograph.Caddyfile"
}
# 1. Host setup: Docker + compose plugin, ufw firewall, and (domain hosts) Caddy.
provisioner "remote-exec" {
inline = [
<<-EOT
set -eu
echo "[${var.name}] setup: docker, compose plugin, firewall"
if ! command -v docker >/dev/null 2>&1; then
curl -fsSL https://get.docker.com | sudo sh
fi
sudo usermod -aG docker "$(id -un)" || true
if ! sudo docker compose version >/dev/null 2>&1; then
sudo apt-get update -y
sudo apt-get install -y docker-compose-plugin
fi
if ! command -v ufw >/dev/null 2>&1; then
sudo apt-get update -y
sudo apt-get install -y ufw
fi
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
DOMAIN='${var.domain}'
if [ -z "$DOMAIN" ]; then
sudo ufw allow ${var.app_port}/tcp
fi
sudo ufw --force enable
if [ -n "$DOMAIN" ]; then
if ! command -v caddy >/dev/null 2>&1; then
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null
sudo apt-get update -y
sudo apt-get install -y caddy
fi
sudo install -m 0644 /tmp/thermograph.Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy || sudo systemctl restart caddy
fi
rm -f /tmp/thermograph.Caddyfile
EOT
]
}
# 2. Sync the checkout to the host's branch (cloning first on a fresh box).
provisioner "remote-exec" {
inline = [
<<-EOT
set -eu
echo "[${var.name}] code: ${var.app_dir} -> origin/${var.git_branch}"
sudo mkdir -p ${var.app_dir}
sudo chown -R "$(id -un)":"$(id -gn)" ${var.app_dir}
if [ ! -d ${var.app_dir}/.git ]; then
git clone ${var.repo_url} ${var.app_dir}
fi
cd ${var.app_dir}
git fetch --prune origin ${var.git_branch}
git reset --hard origin/${var.git_branch}
EOT
]
}
# 3. Install /etc/thermograph.env, then bring the stack up. docker runs as root
# (sources the env file in the same shell) so it never depends on the docker
# group membership taking effect in this session.
provisioner "remote-exec" {
inline = [
<<-EOT
set -eu
echo "[${var.name}] deploy: install env + docker compose up"
sudo install -m 0640 -o root -g root /tmp/thermograph.env /etc/thermograph.env
rm -f /tmp/thermograph.env
sudo bash -c 'set -a; . /etc/thermograph.env; set +a; cd ${var.app_dir} && docker compose ${local.compose_flags} up -d --build'
EOT
]
}
# 4. Health check the app on loopback.
provisioner "remote-exec" {
inline = [
<<-EOT
set -eu
echo "[${var.name}] health: http://127.0.0.1:${var.app_port}/"
ok=0
i=0
while [ "$i" -lt 30 ]; do
if curl -fsS -o /dev/null "http://127.0.0.1:${var.app_port}/"; then ok=1; break; fi
i=$((i + 1))
sleep 2
done
if [ "$ok" != 1 ]; then
echo "[${var.name}] HEALTH CHECK FAILED" >&2
sudo bash -c 'cd ${var.app_dir} && docker compose ${local.compose_flags} ps; docker compose ${local.compose_flags} logs --tail=50 app' || true
exit 1
fi
echo "[${var.name}] OK: serving on 127.0.0.1:${var.app_port}"
EOT
]
}
}
output "host" {
description = "Address this module manages."
value = var.host
}
output "role" {
description = "Role of this host."
value = var.role
}

View file

@ -0,0 +1,18 @@
# /etc/caddy/Caddyfile — RENDERED BY TERRAFORM for ${domain}.
# Caddy provisions a Let's Encrypt cert on first request and auto-renews; the domain's
# A/AAAA record must already point at this host and ports 80/443 must be open.
#
# The app owns the domain root and runs with THERMOGRAPH_BASE=/, so everything is
# reverse-proxied to the single uvicorn on 127.0.0.1:${port}.
#
# NOTE: the repo's deploy/Caddyfile additionally serves the emigriffith.dev portfolio
# and legacy redirects; those are host-specific and intentionally not templated here.
${domain} {
encode zstd gzip
reverse_proxy 127.0.0.1:${port}
log {
output file /var/log/caddy/thermograph.log
}
}

View file

@ -0,0 +1,89 @@
# /etc/thermograph.env — RENDERED BY TERRAFORM. Do not edit on the host; change the
# tfvars and re-apply. Read by `docker compose` (interpolation) and loaded into the
# app container (env_file in docker-compose.yml). Mirrors deploy/thermograph.env.example.
# Port uvicorn binds inside the container (compose publishes it on the host).
PORT=${app_port}
# --- PostgreSQL (docker-compose stack) ------------------------------------------
# Initializes the db container AND builds the app's THERMOGRAPH_DATABASE_URL.
POSTGRES_PASSWORD=${postgres_password}
# Provided explicitly for parity with the example; the app service also derives this
# from POSTGRES_PASSWORD in docker-compose.yml.
THERMOGRAPH_DATABASE_URL=${database_url}
# --- Container sizing (consumed by docker-compose.yml interpolation) -------------
# Terraform sizes the containers per host. Defaults in compose are 4 / 4 / 2 / 8g.
WORKERS=${workers}
APP_CPUS=${app_cpus}
DB_CPUS=${db_cpus}
DB_MEMORY=${db_memory}
# --- Serving --------------------------------------------------------------------
THERMOGRAPH_BASE=${base}
THERMOGRAPH_BASE_URL=${base_url}
# Secure cookie is only sent over HTTPS: 1 behind Caddy TLS, 0 on a plain-HTTP host.
THERMOGRAPH_COOKIE_SECURE=${cookie_secure}
# --- Auth -----------------------------------------------------------------------
# Signs email-confirm / password-reset tokens; pinned so links survive restarts.
THERMOGRAPH_AUTH_SECRET=${auth_secret}
# --- Web Push (VAPID) -----------------------------------------------------------
# Pinned so existing browser subscriptions keep receiving across restarts.
THERMOGRAPH_VAPID_PRIVATE_KEY=${vapid_private_key}
THERMOGRAPH_VAPID_PUBLIC_KEY=${vapid_public_key}
%{ if vapid_contact != "" ~}
THERMOGRAPH_VAPID_CONTACT=${vapid_contact}
%{ endif ~}
# --- SEO verification (optional) ------------------------------------------------
%{ if google_verify != "" ~}
THERMOGRAPH_GOOGLE_VERIFY=${google_verify}
%{ endif ~}
%{ if bing_verify != "" ~}
THERMOGRAPH_BING_VERIFY=${bing_verify}
%{ endif ~}
# --- Outbound email (optional) --------------------------------------------------
%{ if mail_backend != "" ~}
THERMOGRAPH_MAIL_BACKEND=${mail_backend}
%{ endif ~}
%{ if smtp_host != "" ~}
THERMOGRAPH_SMTP_HOST=${smtp_host}
%{ endif ~}
%{ if smtp_port != "" ~}
THERMOGRAPH_SMTP_PORT=${smtp_port}
%{ endif ~}
%{ if smtp_user != "" ~}
THERMOGRAPH_SMTP_USER=${smtp_user}
%{ endif ~}
%{ if smtp_password != "" ~}
THERMOGRAPH_SMTP_PASSWORD=${smtp_password}
%{ endif ~}
%{ if smtp_starttls != "" ~}
THERMOGRAPH_SMTP_STARTTLS=${smtp_starttls}
%{ endif ~}
%{ if mail_from != "" ~}
THERMOGRAPH_MAIL_FROM=${mail_from}
%{ endif ~}
%{ if mail_reply_to != "" ~}
THERMOGRAPH_MAIL_REPLY_TO=${mail_reply_to}
%{ endif ~}
# --- Discord (optional) ---------------------------------------------------------
%{ if discord_webhook != "" ~}
THERMOGRAPH_DISCORD_WEBHOOK=${discord_webhook}
%{ endif ~}
%{ if discord_public_key != "" ~}
THERMOGRAPH_DISCORD_PUBLIC_KEY=${discord_public_key}
%{ endif ~}
%{ if discord_app_id != "" ~}
THERMOGRAPH_DISCORD_APP_ID=${discord_app_id}
%{ endif ~}
%{ if discord_bot_token != "" ~}
THERMOGRAPH_DISCORD_BOT_TOKEN=${discord_bot_token}
%{ endif ~}
%{ if discord_client_secret != "" ~}
THERMOGRAPH_DISCORD_CLIENT_SECRET=${discord_client_secret}
%{ endif ~}

View file

@ -0,0 +1,175 @@
# Per-host inputs (all supplied by the root module's for_each).
variable "name" {
description = "Short host key (e.g. \"prod\", \"dev\"), used in log lines."
type = string
}
variable "host" {
description = "IP or hostname to SSH to."
type = string
}
variable "ssh_user" {
description = "SSH login user (must be able to sudo)."
type = string
}
variable "ssh_private_key_path" {
description = "Path to the private key file for ssh_user."
type = string
}
variable "role" {
description = "\"prod\" | \"dev\" — informational."
type = string
}
variable "git_branch" {
description = "Branch the host checkout is reset to."
type = string
}
variable "domain" {
description = "Public domain. \"\" => no Caddy/TLS (open the app port instead)."
type = string
}
variable "compose_files" {
description = "Compose files to layer, in order (dev appends docker-compose.dev.yml)."
type = list(string)
}
variable "app_dir" {
description = "Checkout path on the host."
type = string
}
variable "repo_root" {
description = "Local repo root, used to hash the compose files for the re-apply trigger."
type = string
}
variable "repo_url" {
description = "Git remote to clone from if the host has no checkout yet."
type = string
}
variable "app_port" {
description = "Port the app binds / is health-checked on."
type = number
}
# ---- Sizing -------------------------------------------------------------------
variable "workers" {
description = "uvicorn worker count (WORKERS)."
type = number
}
variable "app_cpus" {
description = "App container CPU cap (APP_CPUS)."
type = number
}
variable "db_cpus" {
description = "DB container CPU cap (DB_CPUS)."
type = number
}
variable "db_memory" {
description = "DB container memory cap (DB_MEMORY), e.g. \"8g\"."
type = string
}
# ---- Secrets rendered into /etc/thermograph.env -------------------------------
variable "postgres_password" {
type = string
sensitive = true
}
variable "auth_secret" {
type = string
sensitive = true
}
variable "vapid_private_key" {
type = string
sensitive = true
}
variable "vapid_public_key" {
type = string
sensitive = true
}
variable "vapid_contact" {
type = string
sensitive = true
}
variable "google_verify" {
type = string
sensitive = true
}
variable "bing_verify" {
type = string
sensitive = true
}
variable "mail_backend" {
type = string
}
variable "smtp_host" {
type = string
}
variable "smtp_port" {
type = string
}
variable "smtp_user" {
type = string
sensitive = true
}
variable "smtp_password" {
type = string
sensitive = true
}
variable "smtp_starttls" {
type = string
}
variable "mail_from" {
type = string
}
variable "mail_reply_to" {
type = string
}
variable "discord_webhook" {
type = string
sensitive = true
}
variable "discord_public_key" {
type = string
}
variable "discord_app_id" {
type = string
}
variable "discord_bot_token" {
type = string
sensitive = true
}
variable "discord_client_secret" {
type = string
sensitive = true
}

View file

@ -0,0 +1,11 @@
terraform {
required_version = ">= 1.6"
required_providers {
# The module drives everything through a null_resource + SSH provisioners.
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}

14
terraform/outputs.tf Normal file
View file

@ -0,0 +1,14 @@
output "prod_url" {
description = "Public URL of the production site."
value = "https://thermograph.org"
}
output "hosts" {
description = "Per-host summary: the address managed and its role."
value = {
for name, mod in module.host : name => {
host = mod.host
role = mod.role
}
}
}

View file

@ -0,0 +1,85 @@
# Copy to terraform.tfvars and fill in real IPs + secrets.
# cp terraform.tfvars.example terraform.tfvars
# terraform.tfvars is gitignored (it holds secrets, and those land in local state).
# NEVER commit real values.
# ---------------------------------------------------------------------------------
# Hosts
# ---------------------------------------------------------------------------------
# Two VPS hosts. (The `dev` branch deploys to the LAN dev server via
# deploy/deploy-dev.sh — that box is NOT managed by Terraform.)
hosts = {
# Production: the NEW 48 GB / 12-core VPS serving thermograph.org (branch `release`).
prod = {
host = "REPLACE_WITH_NEW_VPS_IP" # <-- the new prod VPS IP/hostname
ssh_user = "deploy"
ssh_private_key_path = "~/.ssh/id_ed25519" # key that can log in as deploy@ and sudo
role = "prod"
git_branch = "release"
domain = "thermograph.org" # Caddy TLS in front, app on loopback
compose_files = ["docker-compose.yml"]
app_dir = "/opt/thermograph"
# Sized up for the big box — tune freely. Also raise the Postgres internal budget
# in deploy/db/init/20-tuning.sql (shared_buffers etc.) to actually use the RAM.
workers = 8
app_cpus = 8
db_cpus = 4
db_memory = "16g"
}
# Beta / testing: the OLD VPS, repurposed (branch `main`).
beta = {
host = "75.119.132.91"
ssh_user = "deploy"
ssh_private_key_path = "~/.ssh/id_ed25519"
role = "beta"
git_branch = "main"
# No public domain by default: no Caddy/TLS, firewall opens the app port. NOTE:
# with compose_files = ["docker-compose.yml"] the app binds 127.0.0.1 only, so
# until you either set a domain (e.g. "beta.thermograph.org", which fronts it with
# Caddy) or add the 0.0.0.0-publishing dev overlay, reach it via an SSH tunnel.
domain = ""
compose_files = ["docker-compose.yml"]
app_dir = "/opt/thermograph"
workers = 4
app_cpus = 4
db_cpus = 2
db_memory = "8g"
}
}
# Optional overrides (shown with their defaults):
# repo_url = "https://github.com/griffemi/thermograph.git"
# app_port = 8137
# ---------------------------------------------------------------------------------
# Shared secrets (DUMMY values — replace, and keep this file out of git)
# ---------------------------------------------------------------------------------
postgres_password = "REPLACE_WITH_A_STRONG_DB_PASSWORD"
# python -c "import secrets; print(secrets.token_urlsafe(48))"
auth_secret = "REPLACE_WITH_A_LONG_RANDOM_STRING"
# cd backend && ../.venv/bin/python -c "import push,json;k=push._generate();print(k['private_key']);print(k['public_key'])"
vapid_private_key = "REPLACE_WITH_VAPID_PRIVATE_KEY"
vapid_public_key = "REPLACE_WITH_VAPID_PUBLIC_KEY"
vapid_contact = "mailto:you@example.com"
# ---- Optional: search-engine verification (leave "" to omit) --------------------
# google_verify = ""
# bing_verify = ""
# ---- Optional: outbound email (leave "" to omit) --------------------------------
# mail_backend = "smtp"
# smtp_host = "127.0.0.1"
# smtp_port = "25"
# smtp_user = ""
# smtp_password = ""
# smtp_starttls = ""
# mail_from = "Thermograph <no-reply@thermograph.org>"
# mail_reply_to = ""
# ---- Optional: Discord (leave "" to omit) ---------------------------------------
# discord_webhook = ""
# discord_public_key = ""
# discord_app_id = ""
# discord_bot_token = ""
# discord_client_secret = ""

183
terraform/variables.tf Normal file
View file

@ -0,0 +1,183 @@
# ---------------------------------------------------------------------------------
# Hosts
# ---------------------------------------------------------------------------------
# One entry per VPS. The same module is instantiated for each (see main.tf's
# for_each). This config manages TWO hosts: prod (new 48 GB / 12-core VPS, branch
# `release`, thermograph.org) and beta (the old VPS 75.119.132.91, branch `main`,
# no public domain by default). The `dev` branch deploys to the LAN dev server via
# deploy/deploy-dev.sh (a self-hosted runner) and is NOT managed here.
#
# A host with `domain = ""` gets no Caddy/TLS: the app port is opened on the firewall
# and the app is reached directly (beta does this by default; set a domain to front
# it with Caddy TLS).
#
# Resource sizing (workers / app_cpus / db_cpus / db_memory) defaults to the historical
# 4 / 4 / 2 / 8g budget (right for beta). The prod box is 48 GB / 12 cores raise these
# there (the example uses app_cpus 8, db_cpus 4, db_memory "16g"); also raise the
# Postgres *internal* budget in deploy/db/init/20-tuning.sql to actually exploit the RAM.
variable "hosts" {
description = "Map of hosts to manage, keyed by a short name (e.g. \"prod\", \"dev\")."
type = map(object({
host = string # IP or hostname to SSH to
ssh_user = optional(string, "deploy") # SSH login user
ssh_private_key_path = string # path to the private key for that user
role = string # "prod" | "dev" (informational + outputs)
git_branch = string # branch the checkout is reset to
domain = optional(string, "") # public domain; "" => no Caddy/TLS
compose_files = optional(list(string), ["docker-compose.yml"])
app_dir = optional(string, "/opt/thermograph") # checkout path on the host
workers = optional(number, 4) # uvicorn workers (WORKERS)
app_cpus = optional(number, 4) # app container CPU cap (APP_CPUS)
db_cpus = optional(number, 2) # db container CPU cap (DB_CPUS)
db_memory = optional(string, "8g") # db container memory cap (DB_MEMORY)
}))
}
variable "repo_url" {
description = "Git remote to clone from when a host has no checkout yet (a fresh prod box)."
type = string
default = "https://github.com/griffemi/thermograph.git"
}
variable "app_port" {
description = "Port the app binds inside the container / is health-checked on."
type = number
default = 8137
}
# ---------------------------------------------------------------------------------
# Shared secrets (rendered into /etc/thermograph.env on every host)
# ---------------------------------------------------------------------------------
# These live in terraform.tfvars (gitignored) and land in local state never commit
# real values. Every host in this config shares the same secret set; split the config
# if prod and dev must diverge on a credential.
variable "postgres_password" {
description = "PostgreSQL password (initializes the db container AND builds THERMOGRAPH_DATABASE_URL)."
type = string
sensitive = true
}
variable "auth_secret" {
description = "THERMOGRAPH_AUTH_SECRET — signs email-confirm / password-reset tokens. Pin it."
type = string
sensitive = true
}
variable "vapid_private_key" {
description = "THERMOGRAPH_VAPID_PRIVATE_KEY — signs Web Push. Pin it or existing subscriptions break."
type = string
sensitive = true
}
variable "vapid_public_key" {
description = "THERMOGRAPH_VAPID_PUBLIC_KEY — public half of the VAPID pair."
type = string
sensitive = true
}
variable "vapid_contact" {
description = "THERMOGRAPH_VAPID_CONTACT — mailto: or https URL sent to push services."
type = string
sensitive = true
default = ""
}
# ---- Optional: search-engine verification -------------------------------------
variable "google_verify" {
description = "THERMOGRAPH_GOOGLE_VERIFY — Search Console HTML-tag content value."
type = string
sensitive = true
default = ""
}
variable "bing_verify" {
description = "THERMOGRAPH_BING_VERIFY — Bing Webmaster msvalidate.01 content value."
type = string
sensitive = true
default = ""
}
# ---- Optional: outbound email (SMTP) ------------------------------------------
variable "mail_backend" {
description = "THERMOGRAPH_MAIL_BACKEND — console | smtp | disabled. Empty => unset (console default)."
type = string
default = ""
}
variable "smtp_host" {
description = "THERMOGRAPH_SMTP_HOST (usually 127.0.0.1 for the local Postfix null client)."
type = string
default = ""
}
variable "smtp_port" {
description = "THERMOGRAPH_SMTP_PORT."
type = string
default = ""
}
variable "smtp_user" {
description = "THERMOGRAPH_SMTP_USER (only for a remote relay)."
type = string
sensitive = true
default = ""
}
variable "smtp_password" {
description = "THERMOGRAPH_SMTP_PASSWORD (only for a remote relay)."
type = string
sensitive = true
default = ""
}
variable "smtp_starttls" {
description = "THERMOGRAPH_SMTP_STARTTLS — \"1\" to enable. Empty => unset."
type = string
default = ""
}
variable "mail_from" {
description = "THERMOGRAPH_MAIL_FROM — From: header, e.g. \"Thermograph <no-reply@thermograph.org>\"."
type = string
default = ""
}
variable "mail_reply_to" {
description = "THERMOGRAPH_MAIL_REPLY_TO."
type = string
default = ""
}
# ---- Optional: Discord --------------------------------------------------------
variable "discord_webhook" {
description = "THERMOGRAPH_DISCORD_WEBHOOK — the URL IS the credential."
type = string
sensitive = true
default = ""
}
variable "discord_public_key" {
description = "THERMOGRAPH_DISCORD_PUBLIC_KEY — verifies interaction requests."
type = string
default = ""
}
variable "discord_app_id" {
description = "THERMOGRAPH_DISCORD_APP_ID."
type = string
default = ""
}
variable "discord_bot_token" {
description = "THERMOGRAPH_DISCORD_BOT_TOKEN — a credential (command registration only)."
type = string
sensitive = true
default = ""
}
variable "discord_client_secret" {
description = "THERMOGRAPH_DISCORD_CLIENT_SECRET — OAuth2 account linking (a credential)."
type = string
sensitive = true
default = ""
}

21
terraform/versions.tf Normal file
View file

@ -0,0 +1,21 @@
terraform {
# Terraform v1.15 is installed; the optional() object defaults used here need >= 1.3.
required_version = ">= 1.6"
# No cloud provider: the hosts already exist. Everything is driven over SSH by the
# null provider's provisioners, with local/random for rendering + generated values.
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}