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:
parent
2e1c746b7c
commit
47aea569ed
14 changed files with 1056 additions and 7 deletions
|
|
@ -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"
|
||||
|
|
|
|||
15
terraform/.gitignore
vendored
Normal file
15
terraform/.gitignore
vendored
Normal 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
|
||||
65
terraform/.terraform.lock.hcl
Normal file
65
terraform/.terraform.lock.hcl
Normal 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
107
terraform/README.md
Normal 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
55
terraform/main.tf
Normal 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
|
||||
}
|
||||
200
terraform/modules/thermograph-host/main.tf
Normal file
200
terraform/modules/thermograph-host/main.tf
Normal 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
|
||||
}
|
||||
18
terraform/modules/thermograph-host/templates/Caddyfile.tftpl
Normal file
18
terraform/modules/thermograph-host/templates/Caddyfile.tftpl
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ~}
|
||||
175
terraform/modules/thermograph-host/variables.tf
Normal file
175
terraform/modules/thermograph-host/variables.tf
Normal 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
|
||||
}
|
||||
11
terraform/modules/thermograph-host/versions.tf
Normal file
11
terraform/modules/thermograph-host/versions.tf
Normal 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
14
terraform/outputs.tf
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
85
terraform/terraform.tfvars.example
Normal file
85
terraform/terraform.tfvars.example
Normal 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
183
terraform/variables.tf
Normal 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
21
terraform/versions.tf
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue