thermograph/docker-compose.yml
Emi Griffith 6fd2d7c981 Containerize the app and move the databases to PostgreSQL 18 (#220)
Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the
data layer on Postgres, while keeping the test suite on SQLite.

- accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write +
  read-only asyncpg pair (the RO engine pins read-only transactions, used by the
  pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the
  SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL
  is unset). models.py: boolean server_default -> sa.false().
- store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg
  when configured, else the existing raw-sqlite3 paths byte-for-byte; sync
  interfaces and every fail-soft contract preserved.
- Alembic (backend/alembic/) manages the accounts schema; the container entrypoint
  runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py
  copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced),
  skips access_token, and resets identity sequences.
- Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137)
  and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet
  climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet').
- deploy.sh/thermograph.service rewired to manage the compose stack; env example,
  Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook.

Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was
verified via docker compose: alembic migrations, register/login, the RO endpoint,
store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00

86 lines
3.6 KiB
YAML

# Thermograph production stack: the FastAPI app plus its PostgreSQL 18 database.
#
# 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 the app's THERMOGRAPH_DATABASE_URL below.
services:
db:
# PostgreSQL 18 + pg_duckdb (deploy/db/Dockerfile.db, FROM pgduckdb/pgduckdb:18-…,
# which IS PostgreSQL 18). Lets the DB read the app's parquet climate cache
# directly, e.g. SELECT * FROM read_parquet('/parquet/cache/*.parquet'). The init
# script CREATE EXTENSIONs pg_duckdb on a fresh volume.
build:
context: .
dockerfile: deploy/db/Dockerfile.db
environment:
POSTGRES_USER: thermograph
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
POSTGRES_DB: thermograph
volumes:
# Mount the volume at the PARENT of the data dir and let the PG18 image use
# its own versioned subdir (/var/lib/postgresql/18/docker). Pinning PGDATA
# directly at the mountpoint trips an initdb chmod on some Docker setups; the
# whole tree still persists on the named volume this way.
- pgdata:/var/lib/postgresql
# Read the app's live parquet cache (the same appdata volume the app writes,
# mounted read-only) — query it under /parquet/cache/*.parquet.
- appdata:/parquet:ro
- ./deploy/db/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U thermograph -d thermograph"]
interval: 5s
timeout: 5s
retries: 10
# Cap the DB at 2 CPUs. Compose v2 honors the top-level `cpus:`; the
# deploy.resources block is the Swarm-style equivalent, kept for parity.
cpus: 2.0
deploy:
resources:
limits:
cpus: "2.0"
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.
app:
build: .
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
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
cpus: 4.0
ports:
# Loopback only — host Caddy terminates TLS and reverse-proxies to this.
- "127.0.0.1:8137:8137"
restart: unless-stopped
volumes:
pgdata: {}
appdata: {}
applogs: {}