diff --git a/docker-compose.yml b/docker-compose.yml index c6e8ef2..040d2f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,17 +41,19 @@ services: # 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 # 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 - # 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. - cpus: 2.0 + cpus: ${DB_CPUS:-2} deploy: resources: limits: - cpus: "2.0" + cpus: "${DB_CPUS:-2}" # Must match mem_limit above (compose rejects distinct values). - memory: 8G + memory: ${DB_MEMORY:-8g} restart: unless-stopped # No host port on purpose: the app reaches Postgres as db:5432 on the # 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_BASE: / 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 # sweep; it lives on the appdata volume so it's shared across workers. THERMOGRAPH_SINGLETON_LOCK: /app/data/notifier.lock @@ -83,7 +87,14 @@ services: # Parquet cache, notifier.lock, homepage.json, vapid.json persist here. - appdata:/app/data - 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: # Loopback only — host Caddy terminates TLS and reverse-proxies to this. - "127.0.0.1:8137:8137" diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..5dabac4 --- /dev/null +++ b/terraform/.gitignore @@ -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 diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..e59e902 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -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", + ] +} diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..0497955 --- /dev/null +++ b/terraform/README.md @@ -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). diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..f02eabd --- /dev/null +++ b/terraform/main.tf @@ -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 +} diff --git a/terraform/modules/thermograph-host/main.tf b/terraform/modules/thermograph-host/main.tf new file mode 100644 index 0000000..44bcd6c --- /dev/null +++ b/terraform/modules/thermograph-host/main.tf @@ -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 +} diff --git a/terraform/modules/thermograph-host/templates/Caddyfile.tftpl b/terraform/modules/thermograph-host/templates/Caddyfile.tftpl new file mode 100644 index 0000000..8e686b2 --- /dev/null +++ b/terraform/modules/thermograph-host/templates/Caddyfile.tftpl @@ -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 + } +} diff --git a/terraform/modules/thermograph-host/templates/thermograph.env.tftpl b/terraform/modules/thermograph-host/templates/thermograph.env.tftpl new file mode 100644 index 0000000..275581a --- /dev/null +++ b/terraform/modules/thermograph-host/templates/thermograph.env.tftpl @@ -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 ~} diff --git a/terraform/modules/thermograph-host/variables.tf b/terraform/modules/thermograph-host/variables.tf new file mode 100644 index 0000000..615c751 --- /dev/null +++ b/terraform/modules/thermograph-host/variables.tf @@ -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 +} diff --git a/terraform/modules/thermograph-host/versions.tf b/terraform/modules/thermograph-host/versions.tf new file mode 100644 index 0000000..86d1276 --- /dev/null +++ b/terraform/modules/thermograph-host/versions.tf @@ -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" + } + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..2336b73 --- /dev/null +++ b/terraform/outputs.tf @@ -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 + } + } +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..2c9a753 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -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 " +# mail_reply_to = "" + +# ---- Optional: Discord (leave "" to omit) --------------------------------------- +# discord_webhook = "" +# discord_public_key = "" +# discord_app_id = "" +# discord_bot_token = "" +# discord_client_secret = "" diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..34da952 --- /dev/null +++ b/terraform/variables.tf @@ -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 \"." + 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 = "" +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..dfd848b --- /dev/null +++ b/terraform/versions.tf @@ -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" + } + } +}