thermograph/infra/docker-compose.yml
emi 92e74c585a
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 13s
Sync infra to hosts / sync-prod (push) Successful in 12s
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 8s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 53s
Deploy frontend to beta VPS / deploy (push) Successful in 1m16s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 6s
frontend: rewrite the SSR content service in Go (#28)
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No
climate math, no DB, no auth -- every route fetches from the backend's
/content/* API.

Verified with a golden-HTML diff, not just unit tests: both the Python
original and the Go rewrite were run against the same committed fixtures
and every route compared byte-for-byte, confirmed programmatically. That
process caught defects unit tests alone missed, since map[string]any has
no compile-time field check:

- Render-context keys were snake_case throughout while the templates read
  PascalCase fields. A missing map key doesn't error, it silently renders
  empty -- title, meta description, canonical URL, OpenGraph tags, and the
  homepage's entire ranked list were blank on every page despite every
  route returning 200. Fixed by renaming every key to match each
  template's own documented field contract, and passing API structs
  straight through wherever their fields already matched (removes a whole
  layer of future drift risk).
- Three pages 500'd: ToolHref needed a composed href, not a bare
  "lat,lon" fragment; the records table needed the raw API struct.
- JSON-LD was double-encoded: <script type="application/ld+json"> is
  JAVASCRIPT context to html/template's escaper regardless of the
  script's type attribute, so template.HTML gets re-escaped as a quoted
  JS string. Needed template.JS. The glossary term page's JSON-LD was
  never built at all -- added.
- html/template silently strips literal HTML and JS comments from parsed
  output (verified in isolation) -- both need a FuncMap function
  returning template.HTML/template.JS to survive.

Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the
Swarm stack's entrypoint override with no explicit command drops the
image's CMD entirely (every deploy would have exited 127), and
COPY --chown by name fails under the classic Docker builder on Alpine.
Both fixed.

go build/vet/test -race clean; docker build passes its embedded test step
under both BuildKit and the classic builder; shellcheck 0 findings.
2026-07-24 00:53:48 +00:00

302 lines
15 KiB
YAML

# Thermograph production stack: backend (FastAPI/API + TestClient(app.py)),
# frontend (the SSR content service), and TimescaleDB (PostgreSQL 18).
#
# FE/BE CI-CD split (finishes repo-split Stage 6/7): backend and frontend are
# two containers, each running its OWN image published by its OWN repo's
# build-push.yml -- backend = ${BACKEND_IMAGE_PATH}, frontend =
# ${FRONTEND_IMAGE_PATH}, tagged independently by BACKEND_IMAGE_TAG /
# FRONTEND_IMAGE_TAG. This replaces the earlier Stage-4 model where both
# containers shared the single emi/thermograph/app image and
# THERMOGRAPH_SERVICE_ROLE picked the process; the split Dockerfiles now start
# the right process directly (frontend the thermograph-frontend Go binary,
# backend entrypoint.sh), so a backend deploy and a frontend deploy are fully
# independent -- deploy.sh rolls one service without touching the other's tag.
# 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.
#
# docker compose up -d --build # or: make up
#
# POSTGRES_PASSWORD must be set at `docker compose` time — compose reads it from
# 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 backend's THERMOGRAPH_DATABASE_URL below.
# Pin the compose project name: the monorepo layout runs compose from
# /opt/thermograph/infra, and without an explicit name the project would be
# derived from that directory ("infra") -- silently a NEW project, recreating
# the whole stack beside the running one. LAN dev overrides this via
# COMPOSE_PROJECT_NAME=thermograph-dev (the env var always wins over this key).
name: thermograph
services:
db:
# TimescaleDB on PostgreSQL 18 (the stock image already sets
# shared_preload_libraries=timescaledb). The app's climate record — the full
# daily archive and the hourly recent+forecast bundle — lives in hypertables
# here (see backend/data/climate_store.py), so the DB, not the filesystem, is the
# source of truth. The init script CREATE EXTENSIONs timescaledb on a fresh volume
# (Alembic also does, idempotently, at app boot).
#
# TIMESCALEDB_TAG defaults to the floating latest-pg18 tag (today's behavior,
# unchanged) so a plain `docker compose up` keeps working with no setup. Pin it
# to an exact minor (e.g. 2.17.2-pg18) before any host of this stack could ever
# replicate with another — a floating tag risks two hosts landing on different
# extension minors, which blocks a physical replica and risks compressed-chunk
# corruption on restore. docker-stack.yml (the Swarm interim stack) REQUIRES an
# exact pin for exactly this reason; use the SAME tag on both once you set one.
image: timescale/timescaledb:${TIMESCALEDB_TAG:-latest-pg18}
environment:
POSTGRES_USER: thermograph
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
POSTGRES_DB: thermograph
# The init tuning script (deploy/db/init/20-tuning.sh) scales the Postgres +
# DuckDB memory GUCs from this budget, matching the mem_limit below. One knob
# per host: Terraform sets DB_MEMORY (prod 16g), local/beta default 8g.
DB_MEMORY: ${DB_MEMORY:-8g}
volumes:
# Mount the volume at the PARENT of the data dir and let the image pick its own
# PGDATA subdir under it (the timescaledb image defaults to
# /var/lib/postgresql/data). Pinning PGDATA directly at the mountpoint trips an
# initdb chmod on some Docker setups; the whole tree still persists this way.
- pgdata:/var/lib/postgresql
- ./deploy/db/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U thermograph -d thermograph"]
interval: 5s
timeout: 5s
retries: 10
# Give Postgres room to cache + process. mem_limit is the hard ceiling; the actual
# budget is tuned in deploy/db/init/20-tuning.sh, which scales shared_buffers (25%),
# effective_cache_size (75%), work_mem and maintenance_work_mem from DB_MEMORY — so
# raising DB_MEMORY raises both the cap and the tuning together. shm_size backs
# parallel-query shared memory (the 64MB docker default is
# too small once shared_buffers/parallelism grow).
# Sized via env (Terraform sets DB_CPUS/DB_MEMORY per host); defaults match the
# historical 2 CPU / 8 GB budget so a plain `docker compose up` is unchanged.
mem_limit: ${DB_MEMORY:-8g}
shm_size: 1gb
# Cap the DB at DB_CPUS CPUs. Compose v2 honors the top-level `cpus:`; the
# deploy.resources block is the Swarm-style equivalent, kept for parity.
cpus: ${DB_CPUS:-2}
deploy:
resources:
limits:
cpus: "${DB_CPUS:-2}"
# Must match mem_limit above (compose rejects distinct values).
memory: ${DB_MEMORY:-8g}
restart: unless-stopped
# No host port on purpose: the app reaches Postgres as db:5432 on the
# compose network. Nothing outside the stack should touch the database.
backend:
# Backend's OWN image, published by thermograph-backend's build-push.yml.
# No `build:` here -- infra holds no Dockerfile; each service's Dockerfile
# lives in its own app repo. deploy.sh sets BACKEND_IMAGE_TAG to the SHA
# build-push.yml pushed for the backend commit being deployed; local dev
# builds `emi/thermograph/backend:local` from the backend repo (see
# infra Makefile) and this pulls/uses it. BACKEND_IMAGE_PATH/TAG are
# independent of the frontend's, so the two services deploy separately.
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local}
depends_on:
db:
condition: service_healthy
environment:
# Built from POSTGRES_PASSWORD; this `environment` value wins over anything
# in env_file, so the URL always matches the db container's password.
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}
# One worker wins this lock and runs the subscription notifier / homepage
# sweep; it lives on the appdata volume so it's shared across workers.
THERMOGRAPH_DATA_DIR: /state
# notifier.lock lives in the state volume, which is now mounted at /state
# (NOT /app/data) so it never shadows the Python `data/` package -- see the
# volumes: note below and thermograph-backend paths.py.
THERMOGRAPH_SINGLETON_LOCK: /state/notifier.lock
# History reads ask the lake service before any third-party source; if
# the lake has no bucket creds (LAN without them exported) it answers
# 503 and climate.py falls straight through to NASA — an accelerator,
# never a dependency.
THERMOGRAPH_LAKE_URL: http://lake:8141
# Shared secret for the /internal/* routes the daemon service calls.
# Interpolated (like POSTGRES_PASSWORD) so local runs get it from the
# repo-root .env; prod/beta have it in /etc/thermograph.env, which
# deploy.sh sources before compose so this resolves there too. Empty =>
# the backend disables the routes and the daemon refuses to start -- fail
# closed on both ends.
THERMOGRAPH_INTERNAL_TOKEN: ${THERMOGRAPH_INTERNAL_TOKEN:-}
# Prod secrets live in /etc/thermograph.env: POSTGRES_PASSWORD,
# THERMOGRAPH_AUTH_SECRET, THERMOGRAPH_VAPID_PRIVATE_KEY/_PUBLIC_KEY,
# THERMOGRAPH_COOKIE_SECURE=1, mail/Discord keys, ... (see
# deploy/thermograph.env.example). `required: false` so local `docker compose
# up` works without that file — it reads POSTGRES_PASSWORD from repo-root .env.
env_file:
- path: /etc/thermograph.env
required: false
volumes:
# Parquet cache, notifier.lock, homepage.json, vapid.json persist here.
# /state, NOT /app/data: after the repo split the backend's Python package
# `data/` sits at /app/data, so mounting the runtime volume there erased
# data/*.py and broke `import data.climate` at boot. THERMOGRAPH_DATA_DIR=/state
# (above) points runtime state here instead, clear of the code.
- appdata:/state
- 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.
cpus: ${APP_CPUS:-4}
deploy:
resources:
limits:
cpus: "${APP_CPUS:-4}"
ports:
# 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
# The ERA5 lake service: partition-pruned reads over the era5-thermograph
# bucket with a local parquet cache, same backend image with
# THERMOGRAPH_ROLE=lake (the entrypoint starts lake_app — no database, no
# migrations, so no depends_on: db). Bucket creds come from
# /etc/thermograph.env where the vault renders them (beta/prod) or from the
# deploy environment (LAN — deploy-dev.yml injects the Actions S3 secrets);
# without them the service stays healthy and every read falls through to
# NASA. Never published on a host port: only the backend talks to it.
lake:
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local}
environment:
THERMOGRAPH_ROLE: lake
PORT: 8141
THERMOGRAPH_SERVICE_ROLE: backend
WORKERS: "1"
THERMOGRAPH_LAKE_CACHE: /state/lake-cache
THERMOGRAPH_LAKE_S3_ACCESS_KEY: ${THERMOGRAPH_LAKE_S3_ACCESS_KEY:-}
THERMOGRAPH_LAKE_S3_SECRET_KEY: ${THERMOGRAPH_LAKE_S3_SECRET_KEY:-}
env_file:
- path: /etc/thermograph.env
required: false
volumes:
- lakecache:/state
cpus: ${LAKE_CPUS:-2}
deploy:
resources:
limits:
cpus: "${LAKE_CPUS:-2}"
restart: unless-stopped
daemon:
# The SAME image and tag as backend, on purpose: the daemon (Go, built into
# the backend image as /usr/local/bin/thermograph-daemon) and the backend
# share the /internal/* API contract, so they must roll together — a
# separate tag could skew them. It owns the Discord gateway websocket and
# the recurring-job timers; anything needing data calls back into backend.
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local}
# Bypass deploy/entrypoint.sh entirely: that script runs Alembic, and the
# backend service's boot already owns migrations — two containers racing
# `alembic upgrade head` against one database is a real hazard, not a
# nicety. The daemon binary never touches the DB.
entrypoint: ["/usr/local/bin/thermograph-daemon"]
# Its first acts are calls to backend's /internal/* routes, so wait for a
# genuinely healthy backend, not just a started container.
depends_on:
backend:
condition: service_healthy
environment:
# Same var the frontend uses for the same purpose: the backend's URL on
# the compose-internal network.
THERMOGRAPH_API_BASE_INTERNAL: http://backend:8137
# See the backend service's note on this var — same interpolation, same
# fail-closed-when-empty behavior on both ends. Normally EMPTY: with no
# explicit value both ends derive the same token from
# THERMOGRAPH_AUTH_SECRET, which arrives via the env_file below. That is
# why this service must keep reading that file even though it serves
# nothing — without the auth secret it has nothing to derive from and
# refuses to start.
THERMOGRAPH_INTERNAL_TOKEN: ${THERMOGRAPH_INTERNAL_TOKEN:-}
# THERMOGRAPH_AUTH_SECRET (derivation input, see above), the bot token/flag
# and the job intervals (THERMOGRAPH_DISCORD_BOT[_TOKEN],
# THERMOGRAPH_*_INTERVAL_HOURS) all arrive via the host env file, same as
# the backend's secrets do — so both processes read one source of truth and
# cannot disagree about the derived token.
env_file:
- path: /etc/thermograph.env
required: false
# The image's HEALTHCHECK curls /healthz on ${PORT} — right for the backend
# process, meaningless for the daemon, which serves nothing. Without this
# the container would sit permanently "unhealthy".
healthcheck:
disable: true
# No volumes: warm-cities/IndexNow work executes inside the BACKEND process
# (the daemon only fires the internal endpoints on a timer), the parquet
# cache and locks stay on backend's appdata volume, and the daemon logs to
# stdout. It holds no state a volume would protect.
#
# No ports: outbound-only (Discord gateway + calls to backend). Publishing
# anything here would only widen the surface for no benefit.
cpus: 0.5
deploy:
resources:
limits:
cpus: "0.5"
restart: unless-stopped
frontend:
# Frontend's OWN image, published by frontend-build-push.yml from
# frontend/Dockerfile (which starts the thermograph-frontend Go binary
# directly -- no THERMOGRAPH_SERVICE_ROLE process-picking). Independent
# FRONTEND_IMAGE_TAG so a frontend deploy never disturbs the backend's tag.
image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-emi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:-local}
# 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: {}
applogs: {}
lakecache: {}
networks:
# Pin the default network's subnet + gateway so the host Postfix can rely on a
# stable address. The app sends verification email by speaking SMTP to the
# gateway (172.19.0.1), where the host Postfix listens and relays out (see
# deploy/provision-mail.sh + THERMOGRAPH_SMTP_HOST in thermograph.env). Without
# the pin, Docker picks a subnet from its pool and the gateway could move.
# 172.19.0.0/16 matches what prod and beta are live on today, so applying this
# is a no-op there.
default:
ipam:
config:
- subnet: 172.19.0.0/16
gateway: 172.19.0.1