DB-tier target architecture — merged kamal config + controlled-failover HA + PITR
Status: Stage 1 (config merge) + the prod app cutover DONE 2026-06-12 — config/deploy.yml
is live as production (bare kamal deploy); the kamal-proxy service was renamed
heatwave-web-production-dallas → heatwave-web via script/cutover_prod_proxy_handoff.sh
(Option A, single ~25-40s .com boot gap; deferred no-op migrate; old containers retired; verified
serving real CRM traffic). The PROD_CUTOVER_PENDING guard in bin/deploy is removed — future
bin/deploy production is a normal in-place rolling update of heatwave-web. Chicago accessory
rename also DONE 2026-06-12 (heatwave-postgres-replica + heatwave-pgbouncer-replica, RO VIP
live; archive_mode NOT yet flipped). STILL PENDING: the Databasus PITR agent + WAL archiving +
the 2 logical-source host updates + the restore-test gate — a focused pass (apply-step 3 below has
the gathered pieces; the Databasus API is finicky). Living doc — amend as decisions firm up.
Current state (2026-06-11)
Section titled “Current state (2026-06-11)”- Bare-metal Latitude, PG18 ping-pong. Dallas (100.123.47.52) = live prod primary PG18.4 —
full stack (web/sidekiq +
postgres/pgbouncer/valkey/playwright) viadeploy.production-dallas.yml. Chicago (100.68.157.49) = streaming standby + RO services viadeploy.production.yml. W3 moves prod home to Chicago. - Routing: Tailscale VIP services (
heatwave-dbRW +heatwave-db-ro) → pgbouncer (session mode) → local postgres. The RW VIP is repointed manually at a flip. - pgbouncer = session mode and must stay so — the app uses session-level advisory locks
(
advisory-locksskill); transaction mode would silently break them (alsoLISTEN/NOTIFY, sessionSET). PG18 + pgbouncer 1.21 named-prepared-statements does NOT rescue advisory locks. - PITR: Databasus agent-mode “Cluster PITR (physical + WAL)” DB created (id
b8a23021-…, token inop://IT/Databasus PITR agent token); the standby recreate (WAL-queue volume +archive_mode=always) is pending. See202606101600_DATABASUS_PITR_PILOT.md. - Config sprawl:
deploy.yml(DEAD Vultr stub),deploy.production.yml(Chicago),deploy.production-dallas.yml(Dallas live),deploy.staging.yml.
Decisions
Section titled “Decisions”- One merged
deploy.yml= production. Accessories:postgres(primary) +postgres_replica(standby) + pgbouncer(s) + valkey + app roles;deploy.staging.yml= staging; delete the dead stub + the two split files. Rationale: failover is external (not kamal) — the kamal config only reflects reality, so at a flip you change the host IP post-promote. The two-file split existed only to keep both DB containers namedheatwave-postgres; with external failover + role-named accessories that trick isn’t needed. - Controlled failover (Approach A), NOT Patroni. External/scripted promote + automatic routing. Patroni/etcd is reserved for if/when a same-DC HA pair + a 3rd-site witness exist and seconds-not-minutes RTO is required (cross-DC auto-failover = split-brain risk; see the earlier HA feasibility analysis). Today: HA = uptime via routing; PITR (Databasus) = the data-loss axis.
- HAProxy routing layer replaces the manual VIP repoint. Two listeners: write → current
primary, read → replica(s). Primary detection — two surveyed mechanisms:
- (A)
pg_hbareject-by-role (Percona): nativeoption pgsql-check user primaryuser/standbyuser; primary rejectsstandbyuserand each standby rejectsprimaryuser. No sidecar, but the failover script must flippg_hbaon every node + reload. - (B)
pg_is_in_recovery()health endpoint (virtualstaticvoid/pgsql_haproxy, Patroni-style): a tiny per-node HTTP/TCP check reporting primary/replica from the LIVE recovery state; HAProxyoption httpchk. Self-detecting — onpg_promote()recovery flipsfalse, the check follows, HAProxy reroutes with NOpg_hbaediting. - Lean (B) — self-detecting; the failover script’s only job is promote + re-stand-up the old primary. Less cross-node state to keep in sync.
- (A)
- pgbouncer stays session-mode (advisory locks — see Current state).
Stages (execute in order)
Section titled “Stages (execute in order)”- Config merge → clean
deploy.yml(the foundation everything else plugs into). Decided. - PITR finish — Databasus agent on the standby; the standby recreate does double duty (WAL-queue
volume + the
postgres_replicarename). In flight —202606101600. - HAProxy + health endpoint (B) + failover script — the controlled-failover routing layer. The
script:
pg_promote()the replica + reconfigure the old primary as a standby; HAProxy reroutes off the recovery-state check. Keep the Tailscale VIP as the stable cross-DC address in front of HAProxy (or fold in). - Rails read/write routing (
ActiveRecord::Middleware::DatabaseSelector) — the replica is currently RO-VIP/analyst-only; app-level read-splitting offloads the primary (GET/HEAD→ replica, writes → primary,delay:to outlast replication lag). Future. - PG18 tuning —
idle_replication_slot_timeout(native fix for the dead-slot-fills-the-primary- disk footgun — relevant to PITR/replication safety; complementsmax_slot_wal_keep_size=200GB),io_method=io_uring(read-heavy replica),uuidv7()for sequential UUID keys. Future.
Stage 1 — config merge: detailed plan (mapped 2026-06-11)
Section titled “Stage 1 — config merge: detailed plan (mapped 2026-06-11)”Live topology found (don’t re-derive):
bin/deploy productionis an alias →.kamal/prod-active-destination(=production-dallas) → live prod app on Dallas (web/sidekiq, container labeldest=production-dallas).config/deploy.production.yml(Chicago) is the idle W3 target, not live.- Accessories: Dallas runs
heatwave-{postgres,pgbouncer,valkey,playwright,sftp}; Chicago runsheatwave-postgres(standby) +heatwave-pgbouncer(the heatwave-db-ro VIP pooler). All on the singlekamaldocker network (prod + staging share it today). sftpis host-pinned to Dallas (100.123.47.52) but defined indeploy.production.yml(Chicago) — a concrete symptom of the split-file mess.
Target merged deploy.yml (= production, reflecting current reality; at W3 you edit the hosts):
service: heatwave; app web/sidekiq on Dallas;proxy.host= prod hostnames; builder remote = Dallas; env →heatwave-pgbouncer/heatwave-valkey. Clean out the stale Vultr base (TODO hosts, db445.63.79.22).- Accessories (all
heatwave-*, networkkamal— prod KEEPSkamalso no prod recreate):postgres(primary) → Dallas/data/prod-replica/data(+/data/prod-replica/tbs)postgres_replica(standby) → Chicago/data/postgres/data(+ tbs) + the PITR/opt/databasus/wal-queue:/wal-queuevolume → containerheatwave-postgres-replicapgbouncer(RW) → Dallas/data/pgbouncer-prodpgbouncer_replica(RO-VIP) → Chicago/data/pgbouncer→ containerheatwave-pgbouncer-replicavalkey→ Dallas;sftp→ Dallas;playwright→ Dallas
deploy.staging.ymlstays on the sharedkamalnetwork — the “split tokamal-staging” idea is not achievable in Kamal 2.11: the app (web/sidekiq) and kamal-proxy containers are hardcoded to--network kamal(commands/app.rb,commands/proxy.rb); only accessories take anetwork:key, and moving just those tokamal-stagingwould break app→accessory DNS. Multi-app isolation is by unique service name instead — the canonical Kamal-2 multi-app pattern (nts.strzibny.name/multiple-apps-single-server-kamal-2: one sharedkamalnet + one shared proxy, distinctservice:per app). Staging floats asheatwave-staging-*; any prod-only accessory it structurally inherits is inert (never auto-booted bykamal deploy). Decided 2026-06-11 (user: “fine as long as all the network names are unique, we don’t need two networks”).
Rename cascade (the *_replica accessories on Chicago — accepted ripple):
- PITR agent
--pg-docker-container-name→heatwave-postgres-replica. pg-maintenance.sh(inprovision-host.sh/ TFC host-config) doesdocker exec heatwave-postgres— parameterizePG_CONTAINERper host (Chicago =heatwave-postgres-replica); it already self-skips on the standby viapg_is_in_recovery().- Chicago RO pgbouncer backend (
/data/pgbouncer/conf.d/databases.ini) host →heatwave-postgres-replica. heatwave-db-roTailscale VIP serve target = the pgbouncer published port → unaffected by the rename.
bin/deploy changes: production → bare kamal deploy (uses deploy.yml); drop the
.kamal/prod-active-destination alias indirection (W3 becomes “edit deploy.yml host”, per the
external-failover model); keep staging → -d staging; delete .kamal/prod-active-destination.
DFLAG is now () for production (empty-array expansion under set -u is fine on the bash 5.x
the deploy runs on), (-d "$DEST") otherwise; the destination-validity check skips the
config/deploy.<dest>.yml existence test for production (it has no per-dest file — it IS the base).
Deletions: deploy.production.yml + deploy.production-dallas.yml (folded into deploy.yml); the
dead Vultr deploy.yml stub IS replaced by the new production deploy.yml. .kamal/secrets.production
→ renamed to .kamal/secrets (see corrections); .kamal/secrets.production-dallas deleted (was an
identical copy).
Two Kamal-2.11 corrections (found while reviewing the committed merge; both applied):
service:override on the*_replicaaccessories. Kamal names the container/DNS as<service>-<accessory-key>, so the keyspostgres_replica/pgbouncer_replicawould resolve toheatwave-postgres_replica/heatwave-pgbouncer_replica(underscore — an invalid hostname that libpq/DNS reject), NOT the hyphenatedheatwave-postgres-replica/heatwave-pgbouncer-replicathe PITR agent / pg-maintenance / RO-pgbouncer backend / every comment assume. Fix: explicitservice: heatwave-postgres-replicaandservice: heatwave-pgbouncer-replicaon the two accessories (Accessory#service_namehonoursservice:; the validator allows it). Verified via YAML render..kamal/secretsbase file. Barekamal deploy(no-d) loads.kamal/secrets-common+.kamal/secrets(Kamal::Secrets#secrets_filenames). The base.kamal/secretsdidn’t exist — only.production/.production-dallas/.staging— soDATABASE_PASSWORD,DATABASE_PASSWORD_VERSIONS, and theproductionenv_key would not resolve. Fix: renamed.kamal/secrets.production→.kamal/secrets.
Apply sequence (gated — file changes are inert w.r.t. live infra; nothing on a DB until step 2):
-
DONE (working tree, pending commit): merged
deploy.yml+ the twoservice:overrides + thebin/deployrework (alias dropped → barekamal deploy) +.kamal/secretsbase file + delete the two dest files +.kamal/secrets.production-dallas+.kamal/prod-active-destination+ dangling-ref cleanup. Why this is inert:kamal deploy(production) boots/swaps only the app on Dallas and ensures the proxy — it does NOT boot accessories and never SSHes to Chicago, and the Dallas app/env/hosts are identical to the liveproduction-dallasconfig. So committing changes only which filebin/deploy productionreads, not any running container. (The bin/deploy cutover IS the alias drop in this step — there’s no separate “cut over” later.) -
DONE 2026-06-12 — Chicago accessory rename.
docker stop heatwave-postgres(kept stopped = rollback) →kamal accessory boot postgres_replica→heatwave-postgres-replica(streaming, 0 lag,/opt/databasus/wal-queue:/wal-queuemounted;/opt/databasus/wal-queuecreated uid 999 on the host). archive_mode lefton(NOT flipped toalways) — deferred to step 3 until the agent’s WAL mechanism is confirmed (avoids an archive backlog with no drain; disk risk is anyway negligible —/opthas 406 GB free + WAL ~idle). Repointed/data/pgbouncer/conf.d/databases.inihost →heatwave-postgres-replica,docker stop heatwave-pgbouncer→kamal accessory boot pgbouncer_replica→heatwave-pgbouncer-replica(RO VIP live). STILL TODO:pg-maintenance.shPG_CONTAINERper host (provision-host.sh / TFC — Chicago =heatwave-postgres-replica; it self-skips on a standby so harmless until W3), and rm the old stoppedheatwave-{postgres,pgbouncer}. -
Finish PITR (remaining — a focused pass; the Databasus API is finicky/undocumented). Pieces gathered:
- Databasus = Postgresus rebrand. Controller
databasuson Chicago:4005(tailnet); adminop://IT/Databasus (Postgres Backup)(username=admin); signinPOST /api/v1/users/signin {email,password}→ JWT. Workspacec5ab3ebb-ce00-4b59-a1fa-8c8f92d0eb9b. List DBs:GET /api/v1/databases?workspace_id=…. - Update the 2 Phase-1 logical sources (else the next scheduled logical backup fails — heatwave daily
03:00 UTC, ~21 h buffer from 2026-06-12 ~06:00 UTC):
4c468fe8-21ab-49e5-8dee-84495636380f(heatwave)d98fb103-621b-4f46-883c-e981b6f6cc7e(versions),postgresql.hostheatwave-postgres→heatwave-postgres-replica. ⚠️ The update endpoint is elusive —PUT/POST/PATCH /databases/{id}hit the SPA catch-all (200 + HTML, no write); passwords come back masked (need the real one =op://IT/Databasus backup role (postgres), len 28). Easiest reliable path = the UI athttp://100.68.157.49:4005, or find the SPA’s actual save call.
- Deploy the agent (binary in the controller image at
/app/agent-binaries/databasus-agent-linux-amd64; commands: start/stop/status/restore). Agent DBb8a23021-b93c-4f12-ad0e-00b5b45c233a(“Cluster PITR”) is a stub (host/container null — the agent configures it). Run on the Chicago host (it docker-execs the container):databasus-agent start -databasus-host http://100.68.157.49:4005 -db-id b8a23021-… -pg-type docker -pg-docker-container-name heatwave-postgres-replica -pg-wal-dir /opt/databasus/wal-queue -token <op://IT/Databasus PITR agent token> -pg-user databasus -pg-password <…role pw…> -pg-port 5432. Watch its logs to learn the WAL mechanism: pg_receivewal (streaming — then NO archive_mode change needed) vs archive_command (thenALTER SYSTEM SET archive_mode='always'+ thecp %p /wal-queue/%f.tmp && mv …command + a restart). Either way add the standby pg_hba linehost replication databasus 127.0.0.1/32 scram-sha-256+ reload for the agent’s replication connection. - Acceptance gate: a real PITR restore to a chosen second into a throwaway container (the whole point;
multi-day) — see
202606101600_DATABASUS_PITR_PILOT.md. Keep SimpleBackups/logical in parallel ≥1 cycle.
- Databasus = Postgresus rebrand. Controller
-
Production cutover is NOT a no-op — it RENAMES the kamal-proxy service. Live prod is registered on the shared Dallas kamal-proxy as
heatwave-web-production-dallas(proxy service = container_prefix =service-role-destination; role.rb:124). A barekamal deploy(destination nil) registers a NEW serviceheatwave-webclaiming the SAME five .com hosts → kamal-proxy rejects it withError: host settings conflict with another service(the new container boots + health-checks, then the deploy aborts and the new container is stopped; live prod keeps serving — graceful, no downtime, but the cutover does NOT complete). To actually cut over, FIRST retire the old service —kamal app stop -d production-dallas(or remove its kamal-proxy host claims) — THEN barekamal deploy. That leaves a ~boot-time (~30–60s) gap on the .com hosts unless you script a manual kamal-proxy host hand-off. Plan it as a deliberate cutover event, not a routine deploy. After this one-time rename, every future barekamal deployis an in-place rolling update ofheatwave-web(no further rename).kamal config+kamal secrets printare verified to resolve the merged config- base secrets, with zero underscore accessory names (2026-06-11).
Surfaced by the staging test deploy, which ALSO caught that the merge made staging inherit prod’s
proxy.host(same conflict class) — fixed:deploy.staging.ymlnow overridesproxy.hostwith staging’s own.wshosts. Staging redeployed clean; prod + staging now coexist on the shared proxy, each host-routed to its distinct hostname set.
References
Section titled “References”fromthekeyboard.com/hosting-multiple-postgres-databases-with-kamal/— merged multi-accessory configpercona.com/blog/configure-haproxy-with-postgresql-using-built-in-pgsql-check/— mechanism (A)github.com/virtualstaticvoid/pgsql_haproxy— mechanism (B), Docker-packaged demo- Rails 8 multi-db
DatabaseSelectorblueprint + HAProxy/Patroni failover blueprint (user-provided) doc/tasks/202606101600_DATABASUS_PITR_PILOT.md— PITR; the earlier Patroni-vs-controlled HA analysis