Repo-split Stage 4: cut prod traffic to backend + frontend, dual-service (#14)

This commit is contained in:
emi 2026-07-21 20:01:30 +00:00
parent ae69c45cb3
commit f25001db69
10 changed files with 152 additions and 58 deletions

View file

@ -63,5 +63,5 @@ jobs:
set -euo pipefail set -euo pipefail
cd /opt/thermograph cd /opt/thermograph
set -a; . /etc/thermograph.env 2>/dev/null || true; set +a 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}" "${THERMOGRAPH_BASE_URL:-https://thermograph.org}"

View file

@ -4,28 +4,48 @@
# (just make sure ports 80 and 443 are open). # (just make sure ports 80 and 443 are open).
# #
# Layout: # 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/ -> static portfolio site (served straight from disk)
# emigriffith.dev/thermograph* -> permanent redirect to thermograph.org (the app moved) # emigriffith.dev/thermograph* -> permanent redirect to thermograph.org (the app moved)
# #
# Thermograph now owns thermograph.org's root, so it must run with # Thermograph now owns thermograph.org's root, so both services run with
# THERMOGRAPH_BASE=/ (see /etc/thermograph.env) — the app serves its pages, assets # THERMOGRAPH_BASE=/ (see /etc/thermograph.env) — pages, assets and API all sit
# and API at "/" with no sub-path prefix. One uvicorn on 127.0.0.1:8137 backs both # at "/" with no sub-path prefix. Repo-split Stage 4: backend and frontend are
# thermograph.org (proxied) and the emigriffith.dev redirect. # 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 { thermograph.org {
encode zstd gzip encode zstd gzip
# The app serves everything at the root now (THERMOGRAPH_BASE=/). @frontend_paths path / /about /privacy /glossary /glossary/* /climate /climate/* /robots.txt /sitemap.xml
# Active health check on the same cheap /healthz route the container's own
# Active health check on the same cheap /healthz route each container's own
# HEALTHCHECK uses (Dockerfile) — so a deploy that's still restarting/booting # 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). # never gets proxied into (a reload alone has no gate, hop-1 runbook hazard #10).
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 { reverse_proxy 127.0.0.1:8137 {
health_uri /healthz health_uri /healthz
health_interval 5s health_interval 5s
health_timeout 3s health_timeout 3s
health_status 2xx health_status 2xx
} }
}
log { log {
output file /var/log/caddy/thermograph.log output file /var/log/caddy/thermograph.log

View file

@ -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. 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 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). `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 (or let the `app` entrypoint do it on first start — but do the data copy before
real traffic). real traffic).
4. **Copy the accounts data:** 4. **Copy the accounts data:**
@ -50,7 +50,7 @@ include `POSTGRES_PASSWORD`, and pinned `THERMOGRAPH_AUTH_SECRET`,
docker compose run --rm \ docker compose run --rm \
-e THERMOGRAPH_DATABASE_URL="postgresql+asyncpg://thermograph:$POSTGRES_PASSWORD@db:5432/thermograph" \ -e THERMOGRAPH_DATABASE_URL="postgresql+asyncpg://thermograph:$POSTGRES_PASSWORD@db:5432/thermograph" \
-v /opt/thermograph/data/accounts.sqlite:/src.sqlite:ro \ -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, It copies user → subscription/push_subscription/pending_digest → notification,
**preserving PKs**, **skips access_token** (everyone re-logins once), and **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 so PGDATA re-inits on the new image: `docker compose down && docker volume rm
<project>_pgdata`. <project>_pgdata`.
3. **Build the schema.** `docker compose up -d db`, then 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`. tables *and* the climate hypertables, and `CREATE EXTENSION timescaledb`.
4. **Restore accounts.** Either `psql < accounts.sql`, or re-run 4. **Restore accounts.** Either `psql < accounts.sql`, or re-run
`migrate_accounts_to_pg.py --sqlite <accounts.sqlite>` (§ the SQLite cutover `migrate_accounts_to_pg.py --sqlite <accounts.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 \ docker compose run --rm \
-v /opt/thermograph/data/cache:/app/data/cache:ro \ -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 It loads `climate_history` + `climate_recent` and sets each cell's
`climate_sync` to the parquet **file mtime**, so `recent_stamp` (hence every `climate_sync` to the parquet **file mtime**, so `recent_stamp` (hence every

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Pull the latest code and roll the docker-compose stack (app + PostgreSQL). Run # Pull the latest code and roll the docker-compose stack (backend + frontend +
# on the VPS — the Forgejo Actions workflow invokes this over SSH, and you can run # PostgreSQL). Run on the VPS — the Forgejo Actions workflow invokes this over
# it by hand too. # SSH, and you can run it by hand too.
# #
# ssh deploy@vps '/opt/thermograph/deploy/deploy.sh' # ssh deploy@vps '/opt/thermograph/deploy/deploy.sh'
set -euo pipefail set -euo pipefail
@ -12,18 +12,18 @@ HEALTH_PORT="${HEALTH_PORT:-8137}"
cd "$APP_DIR" cd "$APP_DIR"
# Secrets (POSTGRES_PASSWORD, VAPID keys, AUTH_SECRET, ...) drive compose # Secrets (POSTGRES_PASSWORD, VAPID keys, AUTH_SECRET, ...) drive compose
# interpolation and are also loaded into the app container via env_file. Source # interpolation and are also loaded into the backend container via env_file.
# them here so a by-hand run interpolates the same as the systemd unit does. # 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 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 # 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 # search-engine crawl never bursts the archive API quota. Detached inside the
# container (compose exec -d), idempotent (skips already-cached cells), so it # backend container (compose exec -d), idempotent (skips already-cached cells),
# never blocks the deploy or health check and is cheap on every deploy after the # so it never blocks the deploy or health check and is cheap on every deploy
# first full warm. # after the first full warm.
warm_city_archives() { warm_city_archives() {
echo "==> Warming city-page archives in the background (app:/app/logs/warm-cities.log)" echo "==> Warming city-page archives in the background (backend:/app/logs/warm-cities.log)"
docker compose exec -d app sh -c \ docker compose exec -d backend sh -c \
'python warm_cities.py --pace 2 >> /app/logs/warm-cities.log 2>&1' || true 'python warm_cities.py --pace 2 >> /app/logs/warm-cities.log 2>&1' || true
} }
@ -33,7 +33,7 @@ warm_city_archives() {
ping_indexnow() { ping_indexnow() {
echo "==> Pinging IndexNow (only if the URL set changed)" echo "==> Pinging IndexNow (only if the URL set changed)"
local base="${THERMOGRAPH_BASE_URL:-https://thermograph.org}" 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 || echo "!! IndexNow ping failed (non-fatal)" >&2
} }
@ -44,9 +44,10 @@ git reset --hard "origin/$BRANCH"
echo "==> Building images" echo "==> Building images"
docker compose build docker compose build
# Schema migrations run inside the app container's entrypoint (alembic upgrade # Schema migrations run inside the backend container's entrypoint (alembic
# head) before uvicorn starts, so there's no separate migrate step or service # upgrade head) before uvicorn starts, so there's no separate migrate step or
# restart here — compose owns the process model. # service restart here — compose owns the process model. frontend has no
# migrations (stateless).
echo "==> Starting stack" echo "==> Starting stack"
docker compose up -d docker compose up -d
@ -63,5 +64,6 @@ for i in $(seq 1 30); do
done done
echo "!! Health check failed for $url" >&2 echo "!! Health check failed for $url" >&2
docker compose ps || true 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 exit 1

View file

@ -15,9 +15,10 @@
# app is flipped over): `make om-backfill`. # app is flipped over): `make om-backfill`.
services: services:
# Serves /v1/archive on the compose network. No host port: only `app` reaches it, # Serves /v1/archive on the compose network. No host port: only `backend`
# as http://open-meteo-api:8080. Reads .om from the object-storage mount, on demand # reaches it, as http://open-meteo-api:8080. Reads .om from the object-storage
# for any point, returning JSON byte-identical to the public archive API. # mount, on demand for any point, returning JSON byte-identical to the public
# archive API.
open-meteo-api: open-meteo-api:
image: ${OM_IMAGE:-ghcr.io/open-meteo/open-meteo} image: ${OM_IMAGE:-ghcr.io/open-meteo/open-meteo}
command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
@ -59,10 +60,11 @@ services:
- ${OM_DATA_DIR:-./data/om-archive}:/app/data - ${OM_DATA_DIR:-./data/om-archive}:/app/data
restart: unless-stopped restart: unless-stopped
# Point the app's historical fetches at the local instance. Set here (not in the # Point backend's historical fetches at the local instance (frontend never
# base file) so it applies only when this overlay is active — and so an unset var # fetches climate data itself). Set here (not in the base file) so it
# never reaches the app as an empty string, which would defeat climate.py's default. # applies only when this overlay is active — and so an unset var never
app: # reaches the app as an empty string, which would defeat climate.py's default.
backend:
depends_on: depends_on:
open-meteo-api: open-meteo-api:
condition: service_started condition: service_started

View file

@ -1,5 +1,14 @@
# Thermograph production stack: the FastAPI app plus its TimescaleDB (PostgreSQL 18) # Thermograph production stack: backend (FastAPI/API + TestClient(app.py)),
# database. # 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 # 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 # 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 # 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 # 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: services:
db: db:
@ -69,7 +78,7 @@ services:
# No host port on purpose: the app reaches Postgres as db:5432 on the # No host port on purpose: the app reaches Postgres as db:5432 on the
# compose network. Nothing outside the stack should touch the database. # compose network. Nothing outside the stack should touch the database.
app: backend:
build: . build: .
depends_on: depends_on:
db: db:
@ -80,6 +89,10 @@ services:
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph
THERMOGRAPH_BASE: / THERMOGRAPH_BASE: /
PORT: 8137 PORT: 8137
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; # 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. # defaults to 4 to keep a plain `docker compose up` identical to before.
WORKERS: ${WORKERS:-4} WORKERS: ${WORKERS:-4}
@ -98,6 +111,9 @@ services:
# Parquet cache, notifier.lock, homepage.json, vapid.json persist here. # Parquet cache, notifier.lock, homepage.json, vapid.json persist here.
- appdata:/app/data - appdata:/app/data
- applogs:/app/logs - applogs:/app/logs
# 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 # 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 # deploy.resources block mirrors the top-level `cpus:` for Swarm parity; the
# dev overlay drops both so dev runs uncapped. # dev overlay drops both so dev runs uncapped.
@ -107,10 +123,33 @@ services:
limits: limits:
cpus: "${APP_CPUS:-4}" cpus: "${APP_CPUS:-4}"
ports: ports:
# Loopback only — host Caddy terminates TLS and reverse-proxies to this. # Loopback only — host Caddy terminates TLS and reverse-proxies to this
# (and, in prod/beta, directly to frontend's own port below too).
- "127.0.0.1:8137:8137" - "127.0.0.1:8137:8137"
restart: unless-stopped 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: volumes:
pgdata: {} pgdata: {}
appdata: {} appdata: {}

View file

@ -62,6 +62,7 @@ locals {
caddy_content = var.domain != "" ? templatefile("${path.module}/templates/Caddyfile.tftpl", { caddy_content = var.domain != "" ? templatefile("${path.module}/templates/Caddyfile.tftpl", {
domain = var.domain domain = var.domain
port = var.app_port port = var.app_port
frontend_port = var.frontend_port
}) : "# No public domain on this host; Caddy is not managed here.\n" }) : "# 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, # 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" { provisioner "remote-exec" {
inline = [ inline = [
<<-EOT <<-EOT
@ -283,10 +287,14 @@ resource "null_resource" "host" {
done done
if [ "$ok" != 1 ]; then if [ "$ok" != 1 ]; then
echo "[${var.name}] HEALTH CHECK FAILED" >&2 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 exit 1
fi fi
echo "[${var.name}] OK: serving on 127.0.0.1:${var.app_port}" 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 EOT
] ]
} }

View file

@ -2,24 +2,40 @@
# Caddy provisions a Let's Encrypt cert on first request and auto-renews; the domain's # 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. # 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 # The app owns the domain root and runs with THERMOGRAPH_BASE=/. Repo-split
# reverse-proxied to the single uvicorn on 127.0.0.1:${port}. # 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 # NOTE: the repo's deploy/Caddyfile additionally serves the emigriffith.dev portfolio
# and legacy redirects; those are host-specific and intentionally not templated here. # and legacy redirects; those are host-specific and intentionally not templated here.
${domain} { ${domain} {
encode zstd gzip 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 # HEALTHCHECK uses (Dockerfile) — so a `docker compose up -d --build` deploy
# that's still restarting/booting never gets proxied into (a reload alone has # 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. # no gate, hop-1 runbook hazard #10). health_uri is relative to the upstream.
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} { reverse_proxy 127.0.0.1:${port} {
health_uri /healthz health_uri /healthz
health_interval 5s health_interval 5s
health_timeout 3s health_timeout 3s
health_status 2xx health_status 2xx
} }
}
log { log {
output file /var/log/caddy/thermograph.log output file /var/log/caddy/thermograph.log

View file

@ -87,10 +87,16 @@ variable "repo_url" {
} }
variable "app_port" { variable "app_port" {
description = "Port the app binds / is health-checked on." description = "Port backend binds / is health-checked on."
type = number 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 ------------------------------------------------------------------- # ---- Sizing -------------------------------------------------------------------
variable "workers" { variable "workers" {
description = "uvicorn worker count (WORKERS)." description = "uvicorn worker count (WORKERS)."

View file

@ -71,6 +71,7 @@ hosts = {
# Optional overrides (shown with their defaults): # Optional overrides (shown with their defaults):
# repo_url = "https://github.com/griffemi/thermograph.git" # repo_url = "https://github.com/griffemi/thermograph.git"
# app_port = 8137 # app_port = 8137
# frontend_port = 8080
# --------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------
# Self-hosted Open-Meteo archive (only used by hosts with openmeteo = true) -------- # Self-hosted Open-Meteo archive (only used by hosts with openmeteo = true) --------