# 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 # # 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. 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: build: . # Named so `docker compose pull` can fetch a prebuilt tag instead of # `--build`ing in place (repo-split Stage 6's registry-pull cutover -- # deploy.sh/Terraform set IMAGE_TAG to the SHA build-push.yml just pushed # for the branch being deployed). Harmless default for local/CI # `--build` usage, which never needs to resolve this against the # registry -- `build:` + `image:` together just tag the local build. image: ${REGISTRY_HOST:-git.thermograph.org}/${IMAGE_PATH:-emi/thermograph/app}:${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_SINGLETON_LOCK: /app/data/notifier.lock # 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. - 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. 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 frontend: build: . # Same image as backend (see backend's `image:` comment above) -- # THERMOGRAPH_SERVICE_ROLE below is what picks the process. image: ${REGISTRY_HOST:-git.thermograph.org}/${IMAGE_PATH:-emi/thermograph/app}:${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: {}