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
|
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}"
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,47 @@
|
||||||
# (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).
|
||||||
reverse_proxy 127.0.0.1:8137 {
|
handle @frontend_paths {
|
||||||
health_uri /healthz
|
reverse_proxy 127.0.0.1:8080 {
|
||||||
health_interval 5s
|
health_uri /healthz
|
||||||
health_timeout 3s
|
health_interval 5s
|
||||||
health_status 2xx
|
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 {
|
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.
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: {}
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,9 @@ locals {
|
||||||
|
|
||||||
# Caddyfile is only meaningful on a host with a public domain.
|
# Caddyfile is only meaningful on a host with a public domain.
|
||||||
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
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,39 @@
|
||||||
# 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.
|
||||||
reverse_proxy 127.0.0.1:${port} {
|
handle @frontend_paths {
|
||||||
health_uri /healthz
|
reverse_proxy 127.0.0.1:${frontend_port} {
|
||||||
health_interval 5s
|
health_uri /healthz
|
||||||
health_timeout 3s
|
health_interval 5s
|
||||||
health_status 2xx
|
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 {
|
log {
|
||||||
|
|
|
||||||
|
|
@ -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)."
|
||||||
|
|
|
||||||
|
|
@ -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) --------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue