11 KiB
PgBouncer Operations Runbook
PgBouncer multiplexes Prisma's per-process connection pools onto a small set
of real Postgres backends. The dev compose stack and the staging Coolify
stack both terminate application traffic at pgbouncer:6432; only the
short-lived deployment migrator receives a separate DIRECT_URL path through
postgres:5432.
Topology
+--------------+ +-------------+ +----------+
| app runtimes | 6432 | pgbouncer | 5432 | postgres |
| app / worker +--------->+ transaction +-------->+ 16-alp. |
| Prisma client| | pool, 25/db | | |
+------+-------+ +-------------+ +-----^----+
|
one-shot migrator -- DIRECT_URL (5432) ---------------+
DATABASE_URL = postgres://...@pgbouncer:6432/<db>- PrismaPg uses unnamed statements by default, which is compatible with transaction-mode pooling.
DATABASE_POOL_MAX=10caps the explicit node-postgres pool. A URLconnection_limitparameter does not configure PrismaPg/node-postgres.
APP_DATABASE_URL,PORTAL_DATABASE_URL,WORKER_DATABASE_URL, and the callback-onlyAUTHENTIK_ADMIN_JIT_DATABASE_URLcontain distinct RLS-enforced roles and point through PgBouncer. Coolify exposes the JIT URL only to the core-web resource; the portal and other services receive only their normalDATABASE_URL.DIRECT_URL = postgres://...@postgres:5432/<db>— supplied only tostart.sh migratein a short-lived deployment step. It must never exist on web, API, MCP, Hocuspocus, or worker resources.
Local development
# Boot Postgres + PgBouncer side by side. The compose service is intentionally
# Docker-network-only — no host port mapping for 6432.
docker compose up postgres pgbouncer
# In another shell:
DIRECT_URL="postgres://user:pass@localhost:5432/hivegrid" \
DATABASE_URL="postgres://user:pass@localhost:6432/hivegrid" \
DATABASE_POOL_MAX=10 \
pnpm dev
If you do not have a Postgres service in docker-compose.yml yet, point both
URLs at your existing local Postgres on port 5432 (skip PgBouncer entirely).
prisma.config.ts falls back to DATABASE_URL when DIRECT_URL is unset.
Coolify (staging) deploy procedure
The pooler must run on the same Docker network as Postgres. The current operator-owned deployment source is the Coolify service/network config; do not copy historical staging inventory into a live cutover.
-
Provision the reviewed roles and generate
userlist.txt. Run the idempotent role provisioner fromdocs/database.mdfirst. Postgres usespassword_encryption=scram-sha-256, so the role-specific SCRAM hashes then live inpg_authid.rolpassword. From the Postgres container:psql -U postgres -tAc \ "SELECT '\"'||rolname||'\" \"'||rolpassword||'\"' FROM pg_authid WHERE rolname IN ( 'hivegrid_app','hivegrid_portal','hivegrid_worker','hivegrid_authentik_jit','pgb_admin' );" > userlist.txt chmod 600 userlist.txtThe query must return all five rows before rollout.
hivegrid_app,hivegrid_portal, andhivegrid_workerhandle their isolated runtime traffic;hivegrid_authentik_jitis the single-function callback capability andpgb_adminis reserved for PgBouncer administration and health checks. Drop the file into a Coolify volume mount that the pgbouncer container will see at/etc/pgbouncer/userlist.txt. Never commit a real hash. -
Create the Coolify service.
- Image:
edoburu/pgbouncer:latest@sha256:…(reviewed manifest digest) - Network:
coolify(must match Postgres) - Mounts:
infra/pgbouncer/pgbouncer.ini→/etc/pgbouncer/pgbouncer.ini:rouserlist.txt(from step 1) →/etc/pgbouncer/userlist.txt:ro
- No public port mapping — Traefik should not see 6432.
- Healthcheck:
pg_isready -h 127.0.0.1 -p 6432 -U pgb_admin
- Image:
-
Wait for the pooler to be
healthy. Verify from inside the network:docker exec <pgbouncer-id> psql -h 127.0.0.1 -p 6432 -U pgb_admin pgbouncer -c "SHOW STATS;" -
Switch runtime env vars. In Coolify:
- core web/API/MCP/Hocuspocus:
DATABASE_URLuses thehivegrid_approle. - customer portal:
DATABASE_URLuses thehivegrid_portalrole. - every worker group:
DATABASE_URLuses thehivegrid_workerrole. - only core web:
AUTHENTIK_ADMIN_JIT_DATABASE_URLuses thehivegrid_authentik_jitrole. Remove every Admin-SSO key from API, MCP, Hocuspocus and worker resources, including inherited groups. - every runtime sets
RLS_RUNTIME_MODE=requiredand the matchingDATABASE_RUNTIME_ROLE. DATABASE_POOL_MAXist auf jeder Runtime explizit gesetzt. Für den ersten Produktionsrollout gilt je Prozess/Instanz: Core-Web10, Customer-Portal5; API/MCP/Hocuspocus und Worker folgen ihrem separat freigegebenen Kapazitätsplan.DATABASE_POOL_IDLE_TIMEOUT_MS=10000DATABASE_POOL_CONNECTION_TIMEOUT_MS=5000- remove
DIRECT_URL,MIGRATOR_*,MIGRATE_ON_BOOT,SEED_ON_BOOT, and allSEED_*keys from every runtime resource, including inherited env groups.
Use service and database names from the current Coolify project state; do not copy archived staging inventory into a live cutover.
- core web/API/MCP/Hocuspocus:
-
Restart apps last. Apps must boot only after the pooler reports healthy; otherwise the first Prisma query fails and the container loops.
Migration runbook (production cutover)
-
Provision PgBouncer in Coolify but leave app env vars unchanged.
-
Verify
SHOW STATSandSHOW POOLSrespond. -
Smoke-test from a one-off container:
docker run --rm --network coolify \ postgres:16-alpine psql \ "postgres://<database-user>:${PG_PASS}@<pgbouncer-service>:6432/<database>" \ -c "SELECT 1;" -
Configure the migrator credential as a CI secret reachable from the runner; do not copy it to Coolify applications.
-
Run the immutable web image once with
DIRECT_URLand argumentmigrate. A missing/unreachable credential or failed migration stops the rollout. -
Roll out the role-specific
DATABASE_URLand explicitDATABASE_POOL_*limits. Coolify will recreate containers — they must reconnect to the pooler. -
Watch
pgbouncerlogs forWARNING|ERROR|FATAL. -
Confirm
prisma migrate statusfrom the same step-scoped CI credential.
Tuning knobs
| Knob | Default | When to change |
|---|---|---|
pool_mode |
transaction |
Stay on transaction for web traffic. Long-lived advisory locks or LISTEN/NOTIFY need session mode on a separate logical database. |
default_pool_size |
25 |
Rule of thumb: postgres.max_connections / number_of_app_pools / 1.5. Hetzner Postgres is provisioned with max_connections=200, so 25 per logical DB × app stack still leaves headroom. |
max_client_conn |
1000 |
Raises the upper bound of client-side connections PgBouncer will accept. Bumping past 1000 has memory cost (~12 KB/conn). |
reserve_pool_size |
5 |
Burst buffer when the main pool is exhausted. Increase only if SHOW POOLS shows recurring cl_waiting. |
server_idle_timeout |
600 |
Lower if Postgres is rapidly recycled (cloud-managed). Higher = fewer reconnects. |
query_timeout |
0 |
Leave at 0. Per-query timeouts live in Prisma / API layer; PgBouncer killing a long report query is hard to debug. |
server_reset_query |
DISCARD ALL |
Required for transaction pooling — prevents tenant state from leaking between transactions on the same backend. Do not weaken. |
Grafana dashboard lives under
infra/grafana/dashboards/ (provisioned by Phase 02 #35).
Panels of interest: pool wait time p95, server connection count vs.
default_pool_size, total clients vs. max_client_conn.
Troubleshooting
FATAL: prepared statement "s0" already exists
PgBouncer in transaction pool mode reused a server-side connection that
still has a prepared statement cached. HiveGrid's PrismaPg adapter uses
unnamed statements by default; a URL pgbouncer=true flag is ignored. Check
for a custom statementNameGenerator, an older non-adapter Prisma client, or
another client sharing the same PgBouncer user.
error: too many clients already
Postgres ran out of connections. Either:
default_pool_size× number-of-app-pools >max_connections— lower pool size or raisemax_connectionson Postgres.DATABASE_POOL_MAXis too high or unset for the deployment budget. Set it explicitly (10 by default, or lower) for each application process.
cl_waiting > 0 persistently in SHOW POOLS
Application sustained pool exhaustion. Options:
- raise
default_pool_size(and double-checkmax_connectionsheadroom), - introduce a read replica (see
03-scaling-infrastructure/read-write-split.md), - inspect slow queries — long-running transactions hold backends.
Migrations fail with "prepared statement" or DDL errors
The step-scoped migrator credential is wrong, missing, or cannot reach Postgres directly. Verify it only inside a one-shot container:
docker run --rm \
-e DIRECT_URL=postgres://...@postgres:5432/db \
<immutable-web-image> migrate
Healthcheck flapping
pg_isready returns non-zero whenever PgBouncer cannot reach Postgres.
Check Postgres health first; PgBouncer surfaces an empty server pool as
unhealthy.
Rollback
To bypass PgBouncer without code changes:
- In Coolify, change
DATABASE_URLto point at Postgres directly (port 5432; pool sizing remains inDATABASE_POOL_*). - Preserve the distinct
hivegrid_app,hivegrid_portal,hivegrid_worker, and callback-onlyhivegrid_authentik_jitcredentials and restart core web, customer portal, API, MCP, Hocuspocus, and workers. The JIT URL remains core-web-only. - Stop the
pgbouncerCoolify service.
The migrator secret remains step-scoped and unchanged; never add it to the runtime resources during rollback.