Skip to content

PgBouncer connection pooling

A session-mode PgBouncer fronts Postgres on both staging and production as a Kamal accessory (heatwave-pgbouncer). This is the architecture + decision record; for operations (rebuild/update, bootstrap, failover) see the pgbouncer skill, and for exact bootstrap commands see config/pgbouncer/README.md.

app (web ./bin/rails, Sidekiq latency + heavy + EDI roles)
│ DATABASE_HOST / DATABASE_HOST_VERSIONS = heatwave-haproxy:6433
heatwave-haproxy (Kamal accessory, write-VIP failover router, :6433, mode tcp passthrough)
│ routes every connection to the CURRENT primary's pgbouncer (health-gated; see below)
heatwave-pgbouncer (Kamal accessory, kamal network, :6432, session mode)
│ pools: heatwave + heatwave_versions
heatwave-postgres (local Postgres accessory, :5432)

Since 2026-06-13 the app no longer points at the bouncer directly — it points at the heatwave-haproxy:6433 write-VIP, a TCP-passthrough failover router that forwards to whichever node is the live primary, so a pg_promote reroutes writes with no databases.ini edit and no app redeploy. PgBouncer is unchanged underneath; HAProxy just sits in front. See HAPROXY.md for the routing layer. (To revert to direct pooling, set DATABASE_HOST / DATABASE_HOST_VERSIONS back to heatwave-pgbouncer:6432.)

The pooler is co-located with the app on each host: the app always talks to a local bouncer (now via the local HAProxy), and the bouncer’s backend is the local Postgres accessory. The heatwaveheatwave_versions FDW is internal to Postgres (loopback) and is unaffected — the bouncer just needs a pool per database.

  1. Cap worker connection demand and deploy overlap. The Kamal cutover initially consolidated Sidekiq into 49 worker threads; PR #1072 sized its pool at DB_POOL=55 (after the default-5 pool caused ActiveRecord::ConnectionTimeoutError across workers — AppSignal #5951–#5961). Sidekiq is now split into latency/heavy/EDI pools of 38+20+5 in production, with 52 worker threads across independent failure domains. With web, steady potential demand is 91 per database under PgBouncer’s 92-connection hard ceiling. Across both application databases that ceiling leaves 13 of PostgreSQL’s 197 normal-user slots for monitoring, auth, and direct access. A rolling deploy briefly runs old and new clients together, so the pooler caps server connections and queues that overlap instead of letting PostgreSQL reject it.
  2. A repointable connection layer for the PG16→18 ping-pong. During the Dallas↔Chicago migration, failover became a backend repoint on the bouncer rather than an app redeploy. (That migration is complete — prod cut over to PG18.4 in Dallas on 2026-06-10.) See the ping-pong runbook.

The app relies on three session-scoped behaviours that transaction pooling would silently break (each transaction would land on a different backend):

  • Advisory locks — 28 files use with_advisory_lock/pg_advisory* (plus the Rails migration advisory lock).
  • LISTEN/NOTIFYapp/models/liquid/order_drop.rb.
  • Per-connection SET statement_timeoutconfig/database.yml variables:.

So pool_mode = session. The trade-off: session mode does not multiplex at steady state (one server connection is bound to a client for its whole session), so the pooler here is a failover-indirection + connection-ceiling layer, not a connection multiplier. default_pool_size covers almost all configured demand; the small steady-state remainder enters the bounded reserve pool after its 1-3 second timeout. max_db_connections is the hard ceiling that only bites during the deploy-overlap storm. A future optimization could route the read-only reading role through a second, transaction-mode port once the Dallas PG18 replica exists.

param value why
pool_mode session advisory locks + LISTEN/NOTIFY + SET (above)
default_pool_size 88 production / 80 staging covers base demand; the bounded reserve can add up to 4 production / 10 staging connections
max_db_connections 92 production / 90 staging, per DB hard ceiling that reserves 13 production / 17 staging PostgreSQL slots outside the two application pools
max_client_conn 2000 absorbs old+new container overlap during a deploy
min_pool_size 10 warm servers for the deploy handoff
query_wait_timeout 60 s production / 30 s staging deploy-overlap clients queue rather than error
server_idle_timeout 600 s reap idle servers so steady state tracks the active set

Steady per-database configured demand is 91 in production (web 28 + Sidekiq 38+20+5) and 90 in staging (web 28 + Sidekiq 38+20+4). These are configured client-pool ceilings, not permanently open backend connections. Production keeps one connection unallocated beneath PgBouncer’s ceiling. See SIDEKIQ_OPERATIONS.md before changing any role pool.

PgBouncer is a session-mode boundary. A Rails connection remains bound to one PgBouncer process and backend session, so replacing that container invalidates every long-lived web and Sidekiq connection through it. An in-place admin RELOAD is appropriate for a reloadable map/config change; a Kamal accessory reboot stops, removes, and recreates the process and is a maintenance event.

For an image, command, environment, or other change that requires a container replacement, first confirm the target environment, record the same live Git revision from every app role as <LIVE_VERSION>, verify through operator coordination that no complete bin/deploy pipeline is running, reserve the maintenance window, and obtain explicit production approval. kamal lock status is useful evidence but cannot see a deploy still building or performing post-deploy work outside Kamal’s narrower mutation lock.

Terminal window
mise exec -- bundle exec kamal app version
mise exec -- bundle exec kamal app details
mise exec -- bundle exec kamal lock status
bin/db maintenance up production
mise exec -- bundle exec kamal accessory reboot pgbouncer
bin/db maintenance down production <LIVE_VERSION>

maintenance up puts the proxy into 503 mode and stops every app role so no client retains a dead pooler session. maintenance down requires the confirmed revision, passes it to kamal app boot, and resumes the proxy only after Kamal has health-gated web and all three Sidekiq roles. A failed stop, boot, or proxy resume returns nonzero; boot failure leaves 503 maintenance enabled. Production also preflights a 30-second timeout/gtimeout wrapper before touching roles, and uses non-interactive SSH for the Databasus controller. An unconfirmed Databasus stop fails the maintenance entry; an unconfirmed restart returns nonzero and reports degraded PITR without taking the recovered app back down. Verify both public /up endpoints, all three worker processes, queue fetch/latency, PgBouncer waiting clients, and PostgreSQL headroom before ending observation. For staging, use the same sequence with staging, the confirmed staging revision, and -d staging where the direct Kamal command requires it.

Do not run this alongside bin/deploy. Kamal’s lock protects each mutating Kamal command, but it does not serialize the wrapper’s earlier build/migration or later post-deploy work. Two operators can therefore overlap most of two release pipelines and execute successive swaps as soon as the first lock is released. Until an end-to-end wrapper lock is implemented, production deploys and datastore accessory maintenance require explicit operator coordination.

At 21:38:32 UTC PgBouncer received SIGTERM during an accessory replacement and was force-killed ten seconds later; its replacement was accepting traffic at 21:38:48. The existing Puma processes retained unusable session-bound connections instead of recovering cleanly. All 20 request slots eventually blocked, /up returned 502, and service recovered only after web was recycled: Puma listened at about 21:43:15 and /up returned 200 at 21:43:26. The customer-visible interruption was about 4.5 minutes.

This was not host memory exhaustion and was not caused by the three-role Sidekiq topology. The operational error was treating an independent forced pooler replacement as transparent to long-lived clients. If it happens again, first confirm the replacement pooler and Postgres are healthy, then recycle web and every Sidekiq role on the already-running application revision. The symptom/recovery checklist is in Kamal troubleshooting.

auth_type = scram-sha-256 + auth_query. A low-privilege pgbouncer login role owns nothing but EXECUTE on pgbouncer.get_auth(text) — a SECURITY DEFINER function (owned by a superuser) that returns a user’s verifier from pg_shadow. PgBouncer authenticates an incoming deploy client against that verifier and relays to the backend via SCRAM pass-through, so the app deploy password is never in the pooler’s files. The only secret stored is the plaintext pgbouncer-role password in /data/pgbouncer/userlist.txt — required because PgBouncer must authenticate as the auth-user to run auth_query, and a stored SCRAM verifier (StoredKey) is one-way and can’t produce a client proof for that login. The pgbouncer role is low-privilege; its password lives in 1Password as Heatwave-PgBouncer-{staging,production}.

ghcr.io/warmlyyours/heatwave-pgbouncer:1.25.2, built from the upstream release tarball (pinned by sha256) via docker/pgbouncer.Dockerfile, Alpine multi-stage, non-root. We build our own rather than depend on a wrapper (edoburu lags upstream) so the supply chain is ours and rebuildable on the next CVE — 1.25.2 carries the SCRAM (CVE-2026-6665) + auth_query search_path (CVE-2025-12819) fixes that land in our auth path. Rebuild/bump procedure: the pgbouncer skill.

  • Staging (dal-latitude-heatwave-01) — live through the bouncer since commit 2c7401da8e; backend Postgres is PG18.
  • Production — fronted by the bouncer since the 2026-06-08 Chicago cutover. The PG16→18 ping-pong moved production to Dallas on PG18.4 (2026-06-10). After the three-role Sidekiq rollout, live configured demand is 91 per database under the 92-connection cap. On 2026-08-01 PostgreSQL had 80 of 200 connections, three active sessions, no lock waits, and no sustained PgBouncer waiting clients.

The two clusters differ in DB bootstrap mechanics (staging trust + deploy-owned /data; prod peer auth → connect as the postgres superuser + root-owned /datasudo) — captured in the pgbouncer skill + config/pgbouncer/README.md.

  • Operations (build/update, bootstrap, failover, gotchas): pgbouncer skill
  • Exact bootstrap commands: config/pgbouncer/README.md
  • Deploys / accessories / 1Password: kamal-deploy skill
  • PG16→18 migration that motivated the repointable layer: ping-pong runbook