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.
4.1 KiB
Cutover: SQLite → PostgreSQL 18 (containerized stack)
The app now runs as a docker-compose stack (app + db) and standardizes on
PostgreSQL. Locally you need nothing but Docker; make up builds and starts both.
This doc is the one-time production cutover from the old bare-systemd/SQLite
deploy to the compose stack, migrating the authoritative accounts data.
What moved
- accounts (users/subscriptions/notifications/…): SQLite → Postgres durable
tables. Managed by Alembic (
backend/alembic/);create_allon a fresh DB. - derived cache (
store.py) and metrics (metrics.py): now Postgres UNLOGGED tables (fast, non-durable — same throwaway semantics). No data to migrate — the cache rebuilds from parquet, metrics start empty. - The app selects Postgres when
THERMOGRAPH_DATABASE_URLis apostgresql+asyncpgURL; unset ⇒ the old SQLite behavior (this is how the test suite stays on SQLite — no Postgres needed in CI). dbruns pg_duckdb (deploy/db/Dockerfile.db, genuine PG18): the app's parquet cache is mounted read-only at/parquet, so you can query it directly, e.g.SELECT r['date'], r['tmax'] FROM read_parquet('/parquet/cache/*.parquet') r.
Connection model
Per worker (4 workers): a read-write asyncpg engine + a read-only asyncpg engine
(default_transaction_read_only=on, used by the pure-GET endpoints), plus one sync
psycopg engine for the notifier thread. Single Postgres primary — the RO engine is
a guardrail, not a replica.
Prod cutover steps (maintenance window)
Prereq: the deploy user can run docker. Secrets in /etc/thermograph.env must
include POSTGRES_PASSWORD, and pinned THERMOGRAPH_AUTH_SECRET,
THERMOGRAPH_VAPID_PRIVATE_KEY/_PUBLIC_KEY, THERMOGRAPH_COOKIE_SECURE=1.
- Ship the stack, don't cut over yet. Deploy the branch;
docker compose build. Bring up only the DB:docker compose up -d db. Add a nightlypg_dumpbackup. - 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). - Build the schema.
docker compose run --rm app alembic upgrade head(or let theappentrypoint do it on first start — but do the data copy before real traffic). - Copy the accounts data:
It copies user → subscription/push_subscription/pending_digest → notification, preserving PKs, skips access_token (everyone re-logins once), and resets the integer-PK sequences (skipping that = a runtime PK collision). Verify the printed per-table counts against the old DB.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 - Start serving:
docker compose up -d(Caddy already proxies127.0.0.1:8137). - Smoke test: login, list/create/delete a subscription, one notifier tick, a cached calendar/day request (derived store on PG), the metrics dashboard.
- Keep the frozen
accounts.sqliteas rollback for a few days; keep PG backed up. Enable pg_duckdb on the (already-initialized) volume once, by hand:docker compose exec db psql -U thermograph -d thermograph -c 'CREATE EXTENSION IF NOT EXISTS pg_duckdb;'
Rollback
Redeploy the previous SQLite release (or point THERMOGRAPH_DATABASE_URL back to
unset/SQLite and restart). Clean only during/immediately after the window — once
real users write to Postgres, those writes are lost on rollback (the copy is
one-way), so keep the window short and writes frozen during the copy.
Notes
- The two
deploy/migrations/*.sqlfiles are superseded by Alembic on Postgres (they were SQLite-specificALTER TABLEcolumn-adds for the old prod DB). - The PG18 image's data dir is the versioned
…/18/dockersubdir; the compose volume is mounted at the parent/var/lib/postgresqlso it persists without the initdb permission issue that pinningPGDATAto the mountpoint can trigger.