Repo-split Stage 4: cut prod traffic to backend + frontend, dual-service (#14)
This commit is contained in:
parent
ae69c45cb3
commit
f25001db69
10 changed files with 152 additions and 58 deletions
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
|
|
|
|||
|
|
@ -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) --------
|
||||
|
|
|
|||
Loading…
Reference in a new issue