From f25001db6913c9367511c65db4690bfc6a53bdf6 Mon Sep 17 00:00:00 2001 From: emi Date: Tue, 21 Jul 2026 20:01:30 +0000 Subject: [PATCH] Repo-split Stage 4: cut prod traffic to backend + frontend, dual-service (#14) --- .forgejo/workflows/ops-cron.yml | 2 +- deploy/Caddyfile | 44 ++++++++++++----- deploy/POSTGRES-MIGRATION.md | 8 +-- deploy/deploy.sh | 34 +++++++------ docker-compose.openmeteo.yml | 16 +++--- docker-compose.yml | 49 +++++++++++++++++-- terraform/modules/thermograph-host/main.tf | 16 ++++-- .../templates/Caddyfile.tftpl | 32 +++++++++--- .../modules/thermograph-host/variables.tf | 8 ++- terraform/terraform.tfvars.example | 1 + 10 files changed, 152 insertions(+), 58 deletions(-) diff --git a/.forgejo/workflows/ops-cron.yml b/.forgejo/workflows/ops-cron.yml index 0b0a64a..d78e53d 100644 --- a/.forgejo/workflows/ops-cron.yml +++ b/.forgejo/workflows/ops-cron.yml @@ -63,5 +63,5 @@ jobs: set -euo pipefail cd /opt/thermograph set -a; . /etc/thermograph.env 2>/dev/null || true; set +a - docker compose exec -T app python indexnow.py --if-changed \ + docker compose exec -T backend python indexnow.py --if-changed \ "${THERMOGRAPH_BASE_URL:-https://thermograph.org}" diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 389d42e..44d48b0 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -4,27 +4,47 @@ # (just make sure ports 80 and 443 are open). # # Layout: -# thermograph.org/* -> the Thermograph app (reverse-proxied to uvicorn) +# thermograph.org/* -> the Thermograph app (path-split across +# backend/frontend -- see below) # emigriffith.dev/ -> static portfolio site (served straight from disk) # emigriffith.dev/thermograph* -> permanent redirect to thermograph.org (the app moved) # -# Thermograph now owns thermograph.org's root, so it must run with -# THERMOGRAPH_BASE=/ (see /etc/thermograph.env) — the app serves its pages, assets -# and API at "/" with no sub-path prefix. One uvicorn on 127.0.0.1:8137 backs both -# thermograph.org (proxied) and the emigriffith.dev redirect. +# Thermograph now owns thermograph.org's root, so both services run with +# THERMOGRAPH_BASE=/ (see /etc/thermograph.env) — pages, assets and API all sit +# at "/" with no sub-path prefix. Repo-split Stage 4: backend and frontend are +# two containers now (docker-compose.yml), each on its own loopback port -- +# Caddy path-splits directly to whichever owns a given path, so the browser +# still sees one apparent origin. The exact path list mirrors +# backend/web/app.py's _FRONTEND_ROUTES; everything not listed (API, the +# interactive tool's SPA pages, static assets, /digest, /discord/interactions, +# and the dynamic IndexNow key file) falls through to backend, which also +# carries its own reverse-proxy fallback for the frontend-owned paths -- so a +# gap in this list degrades to "one extra hop", never a 404. thermograph.org { encode zstd gzip - # The app serves everything at the root now (THERMOGRAPH_BASE=/). - # Active health check on the same cheap /healthz route the container's own + @frontend_paths path / /about /privacy /glossary /glossary/* /climate /climate/* /robots.txt /sitemap.xml + + # Active health check on the same cheap /healthz route each container's own # HEALTHCHECK uses (Dockerfile) — so a deploy that's still restarting/booting # never gets proxied into (a reload alone has no gate, hop-1 runbook hazard #10). - reverse_proxy 127.0.0.1:8137 { - health_uri /healthz - health_interval 5s - health_timeout 3s - health_status 2xx + handle @frontend_paths { + reverse_proxy 127.0.0.1:8080 { + health_uri /healthz + health_interval 5s + health_timeout 3s + health_status 2xx + } + } + + handle { + reverse_proxy 127.0.0.1:8137 { + health_uri /healthz + health_interval 5s + health_timeout 3s + health_status 2xx + } } log { diff --git a/deploy/POSTGRES-MIGRATION.md b/deploy/POSTGRES-MIGRATION.md index d512445..437697f 100644 --- a/deploy/POSTGRES-MIGRATION.md +++ b/deploy/POSTGRES-MIGRATION.md @@ -42,7 +42,7 @@ include `POSTGRES_PASSWORD`, and pinned `THERMOGRAPH_AUTH_SECRET`, Bring up only the DB: `docker compose up -d db`. Add a nightly `pg_dump` backup. 2. **Freeze + back up.** Stop the old app (hard stop — no writes). Back up the live `data/accounts.sqlite` + `-wal`/`-shm` (this is the rollback artifact). -3. **Build the schema.** `docker compose run --rm app alembic upgrade head` +3. **Build the schema.** `docker compose run --rm backend alembic upgrade head` (or let the `app` entrypoint do it on first start — but do the data copy before real traffic). 4. **Copy the accounts data:** @@ -50,7 +50,7 @@ include `POSTGRES_PASSWORD`, and pinned `THERMOGRAPH_AUTH_SECRET`, docker compose run --rm \ -e THERMOGRAPH_DATABASE_URL="postgresql+asyncpg://thermograph:$POSTGRES_PASSWORD@db:5432/thermograph" \ -v /opt/thermograph/data/accounts.sqlite:/src.sqlite:ro \ - app python migrate_accounts_to_pg.py --sqlite /src.sqlite + backend python migrate_accounts_to_pg.py --sqlite /src.sqlite ``` It copies user → subscription/push_subscription/pending_digest → notification, **preserving PKs**, **skips access_token** (everyone re-logins once), and @@ -86,7 +86,7 @@ volume also holds the durable **accounts** data. So this cutover is not a plain so PGDATA re-inits on the new image: `docker compose down && docker volume rm _pgdata`. 3. **Build the schema.** `docker compose up -d db`, then - `docker compose run --rm app alembic upgrade head` — this creates the accounts + `docker compose run --rm backend alembic upgrade head` — this creates the accounts tables *and* the climate hypertables, and `CREATE EXTENSION timescaledb`. 4. **Restore accounts.** Either `psql < accounts.sql`, or re-run `migrate_accounts_to_pg.py --sqlite ` (§ the SQLite cutover @@ -96,7 +96,7 @@ volume also holds the durable **accounts** data. So this cutover is not a plain ``` docker compose run --rm \ -v /opt/thermograph/data/cache:/app/data/cache:ro \ - app python migrate_cache_to_pg.py + backend python migrate_cache_to_pg.py ``` It loads `climate_history` + `climate_recent` and sets each cell's `climate_sync` to the parquet **file mtime**, so `recent_stamp` (hence every diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 4cee2cb..af8e041 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Pull the latest code and roll the docker-compose stack (app + PostgreSQL). Run -# on the VPS — the Forgejo Actions workflow invokes this over SSH, and you can run -# it by hand too. +# Pull the latest code and roll the docker-compose stack (backend + frontend + +# PostgreSQL). Run on the VPS — the Forgejo Actions workflow invokes this over +# SSH, and you can run it by hand too. # # ssh deploy@vps '/opt/thermograph/deploy/deploy.sh' set -euo pipefail @@ -12,18 +12,18 @@ HEALTH_PORT="${HEALTH_PORT:-8137}" cd "$APP_DIR" # Secrets (POSTGRES_PASSWORD, VAPID keys, AUTH_SECRET, ...) drive compose -# interpolation and are also loaded into the app container via env_file. Source -# them here so a by-hand run interpolates the same as the systemd unit does. +# interpolation and are also loaded into the backend container via env_file. +# Source them here so a by-hand run interpolates the same as the systemd unit does. set -a; . /etc/thermograph.env 2>/dev/null || true; set +a # Pre-warm the ~750 city-page archives so /climate pages serve from cache and a -# search-engine crawl never bursts the archive API quota. Detached inside the app -# container (compose exec -d), idempotent (skips already-cached cells), so it -# never blocks the deploy or health check and is cheap on every deploy after the -# first full warm. +# search-engine crawl never bursts the archive API quota. Detached inside the +# backend container (compose exec -d), idempotent (skips already-cached cells), +# so it never blocks the deploy or health check and is cheap on every deploy +# after the first full warm. warm_city_archives() { - echo "==> Warming city-page archives in the background (app:/app/logs/warm-cities.log)" - docker compose exec -d app sh -c \ + echo "==> Warming city-page archives in the background (backend:/app/logs/warm-cities.log)" + docker compose exec -d backend sh -c \ 'python warm_cities.py --pace 2 >> /app/logs/warm-cities.log 2>&1' || true } @@ -33,7 +33,7 @@ warm_city_archives() { ping_indexnow() { echo "==> Pinging IndexNow (only if the URL set changed)" local base="${THERMOGRAPH_BASE_URL:-https://thermograph.org}" - docker compose exec -T app python indexnow.py --if-changed "$base" \ + docker compose exec -T backend python indexnow.py --if-changed "$base" \ || echo "!! IndexNow ping failed (non-fatal)" >&2 } @@ -44,9 +44,10 @@ git reset --hard "origin/$BRANCH" echo "==> Building images" docker compose build -# Schema migrations run inside the app container's entrypoint (alembic upgrade -# head) before uvicorn starts, so there's no separate migrate step or service -# restart here — compose owns the process model. +# Schema migrations run inside the backend container's entrypoint (alembic +# upgrade head) before uvicorn starts, so there's no separate migrate step or +# service restart here — compose owns the process model. frontend has no +# migrations (stateless). echo "==> Starting stack" docker compose up -d @@ -63,5 +64,6 @@ for i in $(seq 1 30); do done echo "!! Health check failed for $url" >&2 docker compose ps || true -docker compose logs --tail=50 app || true +docker compose logs --tail=50 backend || true +docker compose logs --tail=50 frontend || true exit 1 diff --git a/docker-compose.openmeteo.yml b/docker-compose.openmeteo.yml index 9db5415..7baa780 100644 --- a/docker-compose.openmeteo.yml +++ b/docker-compose.openmeteo.yml @@ -15,9 +15,10 @@ # app is flipped over): `make om-backfill`. services: - # Serves /v1/archive on the compose network. No host port: only `app` reaches it, - # as http://open-meteo-api:8080. Reads .om from the object-storage mount, on demand - # for any point, returning JSON byte-identical to the public archive API. + # Serves /v1/archive on the compose network. No host port: only `backend` + # reaches it, as http://open-meteo-api:8080. Reads .om from the object-storage + # mount, on demand for any point, returning JSON byte-identical to the public + # archive API. open-meteo-api: image: ${OM_IMAGE:-ghcr.io/open-meteo/open-meteo} command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] @@ -59,10 +60,11 @@ services: - ${OM_DATA_DIR:-./data/om-archive}:/app/data restart: unless-stopped - # Point the app's historical fetches at the local instance. Set here (not in the - # base file) so it applies only when this overlay is active — and so an unset var - # never reaches the app as an empty string, which would defeat climate.py's default. - app: + # Point backend's historical fetches at the local instance (frontend never + # fetches climate data itself). Set here (not in the base file) so it + # applies only when this overlay is active — and so an unset var never + # reaches the app as an empty string, which would defeat climate.py's default. + backend: depends_on: open-meteo-api: condition: service_started diff --git a/docker-compose.yml b/docker-compose.yml index a987c60..8d9ad91 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,14 @@ -# Thermograph production stack: the FastAPI app plus its TimescaleDB (PostgreSQL 18) -# database. +# Thermograph production stack: backend (FastAPI/API + TestClient(app.py)), +# frontend (the SSR content service), and TimescaleDB (PostgreSQL 18). +# +# Repo-split Stage 4: backend and frontend are two containers built from the +# SAME image (THERMOGRAPH_SERVICE_ROLE picks which process runs -- see +# Dockerfile/deploy/entrypoint.sh), not one "app" service anymore. Both get +# their own loopback-published port since prod/beta's host Caddy path-splits +# directly to each; backend also gets a reverse-proxy fallback to frontend +# (THERMOGRAPH_FRONTEND_BASE_INTERNAL) for any environment with no Caddy in +# front (LAN dev, bare-metal run.sh) -- see backend/web/app.py's +# _proxy_to_frontend and the repo-split plan's Stage 4 section. # # docker compose up -d --build # or: make up # @@ -7,7 +16,7 @@ # the repo-root .env for local runs (copy .env.example -> .env), and in prod the # systemd unit's EnvironmentFile=/etc/thermograph.env puts it in the environment # so `docker compose up` can interpolate it. It is used BOTH to initialize the db -# container and to build the app's THERMOGRAPH_DATABASE_URL below. +# container and to build backend's THERMOGRAPH_DATABASE_URL below. services: db: @@ -69,7 +78,7 @@ services: # No host port on purpose: the app reaches Postgres as db:5432 on the # compose network. Nothing outside the stack should touch the database. - app: + backend: build: . depends_on: db: @@ -80,6 +89,10 @@ services: THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph THERMOGRAPH_BASE: / PORT: 8137 + THERMOGRAPH_SERVICE_ROLE: backend + # Docker's built-in service-name DNS -- reachable only inside the compose + # network. See backend/web/app.py's _proxy_to_frontend. + THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://frontend:8080 # 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} @@ -98,6 +111,9 @@ services: # Parquet cache, notifier.lock, homepage.json, vapid.json persist here. - appdata:/app/data - applogs:/app/logs + # No compose-level healthcheck override -- the image's own Dockerfile + # HEALTHCHECK (port-aware via ${PORT}) already covers this, and frontend's + # depends_on below reads it the same way. # 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. @@ -107,10 +123,33 @@ services: limits: cpus: "${APP_CPUS:-4}" ports: - # Loopback only — host Caddy terminates TLS and reverse-proxies to this. + # Loopback only — host Caddy terminates TLS and reverse-proxies to this + # (and, in prod/beta, directly to frontend's own port below too). - "127.0.0.1:8137:8137" restart: unless-stopped + frontend: + build: . + # Its own register() fetches the IndexNow key from backend at boot -- must + # wait for a real, healthy backend, not just a started container. + depends_on: + backend: + condition: service_healthy + environment: + THERMOGRAPH_BASE: / + PORT: 8080 + THERMOGRAPH_SERVICE_ROLE: frontend + THERMOGRAPH_API_BASE_INTERNAL: http://backend:8137 + # No volumes -- stateless, holds no data of its own. + cpus: ${FRONTEND_CPUS:-2} + deploy: + resources: + limits: + cpus: "${FRONTEND_CPUS:-2}" + ports: + - "127.0.0.1:8080:8080" + restart: unless-stopped + volumes: pgdata: {} appdata: {} diff --git a/terraform/modules/thermograph-host/main.tf b/terraform/modules/thermograph-host/main.tf index 8eedd29..c39c5bb 100644 --- a/terraform/modules/thermograph-host/main.tf +++ b/terraform/modules/thermograph-host/main.tf @@ -60,8 +60,9 @@ locals { # 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 + domain = var.domain + port = var.app_port + frontend_port = var.frontend_port }) : "# No public domain on this host; Caddy is not managed here.\n" # systemd unit that keeps the object-storage bucket rclone-mounted at om_data_dir, @@ -268,7 +269,10 @@ resource "null_resource" "host" { ] } - # 4. Health check the app on loopback. + # 4. Health check backend on loopback -- a plain "/" here already exercises + # the whole chain end to end even without Caddy in front (backend's own + # reverse-proxy fallback forwards to frontend), so one curl covers both + # services in every topology this module supports (repo-split Stage 4). provisioner "remote-exec" { inline = [ <<-EOT @@ -283,10 +287,14 @@ resource "null_resource" "host" { 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 + sudo bash -c 'cd ${var.app_dir} && docker compose ${local.compose_flags} ps; docker compose ${local.compose_flags} logs --tail=50 backend; docker compose ${local.compose_flags} logs --tail=50 frontend' || true exit 1 fi echo "[${var.name}] OK: serving on 127.0.0.1:${var.app_port}" + if ! curl -fsS -o /dev/null "http://127.0.0.1:${var.frontend_port}/healthz"; then + echo "[${var.name}] frontend's own /healthz failed directly (backend's proxy to it may still work) -- check separately" >&2 + sudo bash -c 'cd ${var.app_dir} && docker compose ${local.compose_flags} logs --tail=50 frontend' || true + fi EOT ] } diff --git a/terraform/modules/thermograph-host/templates/Caddyfile.tftpl b/terraform/modules/thermograph-host/templates/Caddyfile.tftpl index fccda31..4141b09 100644 --- a/terraform/modules/thermograph-host/templates/Caddyfile.tftpl +++ b/terraform/modules/thermograph-host/templates/Caddyfile.tftpl @@ -2,23 +2,39 @@ # 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}. +# The app owns the domain root and runs with THERMOGRAPH_BASE=/. Repo-split +# Stage 4: backend (127.0.0.1:${port}) and frontend (127.0.0.1:${frontend_port}) +# are two containers now -- path-split directly to whichever owns a given path, +# same list backend/web/app.py's own reverse-proxy fallback (for Caddy-less +# hosts) uses, so the two never disagree about who owns what. # # 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 - # Active health check on the same cheap /healthz route the container's own + @frontend_paths path / /about /privacy /glossary /glossary/* /climate /climate/* /robots.txt /sitemap.xml + + # Active health check on the same cheap /healthz route each container's own # HEALTHCHECK uses (Dockerfile) — so a `docker compose up -d --build` deploy # that's still restarting/booting never gets proxied into (a reload alone has # no gate, hop-1 runbook hazard #10). health_uri is relative to the upstream. - reverse_proxy 127.0.0.1:${port} { - health_uri /healthz - health_interval 5s - health_timeout 3s - health_status 2xx + handle @frontend_paths { + reverse_proxy 127.0.0.1:${frontend_port} { + health_uri /healthz + health_interval 5s + health_timeout 3s + health_status 2xx + } + } + + handle { + reverse_proxy 127.0.0.1:${port} { + health_uri /healthz + health_interval 5s + health_timeout 3s + health_status 2xx + } } log { diff --git a/terraform/modules/thermograph-host/variables.tf b/terraform/modules/thermograph-host/variables.tf index 66b804f..05be848 100644 --- a/terraform/modules/thermograph-host/variables.tf +++ b/terraform/modules/thermograph-host/variables.tf @@ -87,10 +87,16 @@ variable "repo_url" { } variable "app_port" { - description = "Port the app binds / is health-checked on." + description = "Port backend binds / is health-checked on." type = number } +variable "frontend_port" { + description = "Port the frontend SSR service binds / is health-checked on (repo-split Stage 4). Loopback-only, never opened in ufw -- reached via Caddy's path-split or backend's own reverse-proxy fallback, never directly." + type = number + default = 8080 +} + # ---- Sizing ------------------------------------------------------------------- variable "workers" { description = "uvicorn worker count (WORKERS)." diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index b926535..369ba66 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -71,6 +71,7 @@ hosts = { # Optional overrides (shown with their defaults): # repo_url = "https://github.com/griffemi/thermograph.git" # app_port = 8137 +# frontend_port = 8080 # --------------------------------------------------------------------------------- # Self-hosted Open-Meteo archive (only used by hosts with openmeteo = true) --------