DB TLS + SCRAM switch-over window
This is the runbook for the owner-scheduled restart window (GSEC Q10 = B): the two internal DB hops — API → PgBouncer and PgBouncer → Postgres — move onto the internal per-service TLS certificates and SCRAM-SHA-256 password auth, in one drained window together with the API image switch.
Everything below is prepared and inert until this window runs:
CORE/startup/db-tls/— the compose override (docker-compose.db-tls.yml), the cert/config fragments, the entrypoint wrappers, andrehash-scram.sh.CORE/startup/pgbouncer/provision-auth.sh— SCRAM-aware since the prep: its userlist entry is now the plaintextpgbouncer_authpassword (the form PgBouncer needs for its own server-side SCRAM handshake; still works against the old md5 Postgres).- The API already reads
POSTGRES_SSLMODE/POSTGRES_SSLROOTCERT(inert R-057 wiring inCORE/API/database.py); this window just sets them.
In plain words
The database side of the house currently talks over the internal Docker
network with no encryption and md5 password checks. The certificates for
Postgres (postgres-app, my_postgres_db) and PgBouncer (pgbouncer,
mod_pgbouncer) were issued at install time and have been auto-renewed
since. This window does three things: (1) re-store the long-lived database
passwords in the stronger SCRAM format, (2) tell Postgres and PgBouncer to
speak TLS on the way to each other, and (3) tell the API to demand TLS and
verify the server's identity certificate. If anything misbehaves, removing
one compose file (the override) and recreating the containers puts the
stack back exactly where it was.
Before the window (prep, no downtime)
cd CORE/startup
# 1. Certs current (idempotent — the watchdog already does this; just confirm):
bash lib/internal-certs.sh "$PWD" renew
openssl verify -CAfile internal-tls/ca-bundle.pem internal-tls/postgres/cert.pem
openssl verify -CAfile internal-tls/ca-bundle.pem internal-tls/pgbouncer/cert.pem
openssl x509 -in internal-tls/postgres/cert.pem -noout -ext subjectAltName
# expect: DNS:postgres-app, DNS:my_postgres_db, DNS:localhost
openssl x509 -in internal-tls/pgbouncer/cert.pem -noout -ext subjectAltName
# expect: DNS:pgbouncer, DNS:mod_pgbouncer, DNS:localhost
# 2. Dry-run the SCRAM re-hash (prints role names + booleans only, never
# passwords/verifiers):
bash db-tls/rehash-scram.sh dry
Pick the mode:
MOD_DB_TLS | pgbouncer → postgres | API connect args |
|---|---|---|
require | server_tls_sslmode=verify-full | POSTGRES_SSLMODE=verify-full |
prefer | server_tls_sslmode=prefer | POSTGRES_SSLMODE=prefer |
require is the target state. (PgBouncer has no "force TLS on clients"
knob; the API's sslmode=verify-full is what makes the client side
mandatory, and pg_hba's hostssl-first ordering keeps the DB hop
TLS-preferred regardless.)
The window (ordered steps)
All commands from CORE/startup. Set
C="docker compose -f docker-compose.yml -f db-tls/docker-compose.db-tls.yml".
0. Pause the agent lanes. The API will be down for ~2–4 minutes; an API
down for more than 120 s makes every worker fence its external GPU
stacks (compose-stops ha_class=external stacks). Either pause the
agent lanes for the window or accept the fences and re-dispatch. Then
drain the platform the usual way (dispatch paused; wait for in-flight
runs/tasks to finish — do not check after, check before you restart).
1. Re-hash the long-lived roles to SCRAM (while the stack still runs on the old configs — rehash needs no TLS at all):
bash db-tls/rehash-scram.sh dry # confirm the plan
bash db-tls/rehash-scram.sh apply # re-hash superuser + pgbouncer_auth,
# set the plaintext userlist entry,
# wire the TLS fragment into postgresql.conf
Confirm from the apply output: scram=true for modTech and
pgbouncer_auth, and "userlist.txt set to the plaintext pgbouncer_auth
entry". (The plaintext entry is what PgBouncer needs for its own SCRAM
server-side handshake — a stored SCRAM verifier can't be used for that.)
2. Flip the install flag (persisted to the startup .env, the same
way MOD_FRONT_TLS_MODE persists; the installer flag --db-tls=require
does this at fresh installs):
bash -c 'source ./lib/mod-env.sh; update_env_var MOD_DB_TLS require; update_env_var MOD_DB_TLS_SSLMODE verify-full'
3. Restart Postgres with the override (this activates TLS on the server; the wrapper copies the certs in with postgres-owned 0600):
$C --profile all up -d --force-recreate postgres-app
docker ps --filter name=my_postgres_db # wait: healthy
docker exec my_postgres_db psql -U modTech -d modTech -tAc 'show ssl' # expect: on
docker exec my_postgres_db psql -U modTech -d modTech -tAc 'show password_encryption' # expect: scram-sha-256
4. Restart PgBouncer (it verifies Postgres with verify-full):
$C --profile all up -d --force-recreate pgbouncer
docker ps --filter name=mod_pgbouncer # wait: healthy (its healthcheck
# probes postgres-app directly —
# that is also the DNS-stranding canary)
docker run --rm --network modtex-network postgres:13 \
psql "postgresql://pgbouncer_auth@pgbouncer:6432/pgbouncer" -c 'SHOW SERVERS'
# server tls column should show a TLS-verified server (login OK = SCRAM works)
DNS-stranding check: if the API (or the
SHOW SERVERSlogin above) can't reach the DB right after these restarts, PgBouncer may have cached an NXDOMAIN for thepostgres-appalias (known failure mode, hardened bydns_nxdomain_ttl=0but the backstop is still manual):docker restart mod_pgbounceraftermy_postgres_dbis healthy. Its healthcheck turninghealthyis the signal it recovered.
5. Recreate the API (image switch + override; api-background and
mod-migrate come along — they share the override):
$C --profile all up -d --force-recreate api api-background # + the release-override pin if mid-update
curl -fsS http://127.0.0.1:8000/health
6. Verify the hops are actually TLS (all commands read-only, print booleans only):
# Postgres sees TLS sessions for the API:
docker exec my_postgres_db psql -U modTech -d modTech \
-c "SELECT ssl, version FROM pg_stat_ssl"
# every row for the API's pooler+direct connections: ssl=t
# End-to-end through PgBouncer, full verification, SCRAM:
docker run --rm --network modtex-network -v "$PWD/internal-tls/ca-bundle.pem:/ca.pem:ro" \
postgres:13 sh -c 'psql "host=pgbouncer port=6432 dbname=modTech sslmode=verify-full sslrootcert=/ca.pem" -c "SELECT 1" ' \
# (supplied with the rotating/known user+password, never printed)
# PgBouncer server-side TLS status:
docker run --rm --network modtex-network postgres:13 \
psql "postgresql://pgbouncer_auth@pgbouncer:6432/pgbouncer" -c 'SHOW POOLS' -c 'SHOW SERVERS'
7. Restore dispatch and un-pause the agent lanes.
Downtime estimate
| Step | Time |
|---|---|
| drain (wait for in-flight) | 0–15 min, usually under 2 |
| Postgres recreate + healthy | ~20–40 s |
| PgBouncer recreate + healthy | ~15–30 s |
| API recreate (incl. image switch) | ~40–120 s |
| API unavailable total | ~2–4 min |
The stack remains DRAINED for the whole window, so users see queueing, not errors; the hard number that matters is the API-down duration versus the 120 s worker-fence threshold — hence step 0.
ROLLBACK
Reverse order, no data loss (migrations are additive; SCRAM-stored hashes
still authenticate under the old md5 pg_hba lines, so rollback never
needs to re-hash anything):
bash -c 'source ./lib/mod-env.sh; update_env_var MOD_DB_TLS off'(and dropMOD_DB_TLS_SSLMODE).- Remove the override from every compose command — i.e. recreate with the
plain file:
(PgBouncer goes back todocker compose -f docker-compose.yml --profile all up -d --force-recreate \api api-background pgbouncer postgres-app
auth_type=md5from the stockpgbouncer.ini; Postgres back tossl=off+ stockpg_hba.) - If the API recreate in the window used a new image, roll the API back to
the previous release pin the way
update.shdoes (write_override_oldpath / previous digest override). - If Postgres itself was recreated with the TLS fragment and would not
come back without the override (it will not — the fragment only adds
TLS, and the base config file is untouched), a plain recreate restores
the stock behaviour; the appended
include/hba_filelines in the volume'spostgresql.confcan then be deleted by hand if desired:docker exec my_postgres_db sh -c \'sed -i "/internal-tls/d" "$PGDATA/postgresql.conf"' - Verify:
show ssl→off;SHOW POOLSon pgbouncer logs in with md5; API/healthOK. Resume dispatch.
What stays true afterwards
- Vault dynamic roles are ephemeral and now store SCRAM verifiers automatically (server default) — no per-role action ever again.
provision-auth.shre-writes the plaintextuserlist.txtentry on every update/install, so future updates can't silently revert the switch (the role's stored hash re-stores as SCRAM automatically from the server default).- Cert renewal is unchanged: the watchdog renews the leaves; the keys are reused, so identities are stable. After a renewal, nothing needs a restart — the containers re-read the copied files on their next start.