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
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}"

View file

@ -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 {

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.
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
<project>_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 <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 \
-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

View file

@ -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

View file

@ -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

View file

@ -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: {}

View file

@ -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
]
}

View file

@ -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 {

View file

@ -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)."

View file

@ -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) --------