Add PgBouncer to a Postgres-Backed Self-Hosted App Without Breaking Application Behavior
Put PgBouncer in front of PostgreSQL to smooth connection spikes, but choose pooling mode deliberately, test the app path, and keep a clean rollback to direct Postgres connections.
Before you begin
- Have a working Postgres-backed app already running.
- Know whether the app uses migrations, background jobs, or long-lived sessions.
- Have a backup or snapshot path before changing the database connection layer.
- Be ready to revert one environment variable change if the app is incompatible.
PgBouncer helps when too many app-side connections waste Postgres memory or thrash connection setup. It is not a general performance spell. Some apps depend on session-level behavior and will break if you pick the wrong pooling mode.
Step 1: Know what PgBouncer is fixing
The common small-stack problem looks like this: a web app, worker, scheduler, and occasional admin task all open connections faster than Postgres needs or wants. PgBouncer keeps a smaller server-side pool while letting the app speak the normal Postgres protocol.
- Good fit: many short-lived requests or worker bursts.
- Risky fit: tools that depend on session state, temporary tables, or prepared-statement assumptions that do not survive transaction pooling.
Step 2: Choose a pooling mode on purpose
Start by understanding the tradeoff:
- Session pooling: safest default when you are not sure. Each client keeps a server connection for the life of its session.
- Transaction pooling: more efficient, but only safe when the app does not rely on session-level state between transactions.
PgBouncer's config docs note that session-oriented reset behavior is different from transaction pooling behavior. That is a clue: the more session state your app assumes, the more cautious you should be.
pool_mode = session, prove stability, then evaluate transaction pooling later.
Step 3: Add PgBouncer to Docker Compose
A minimal Compose fragment looks like this:
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: appdb
POSTGRES_USER: app
POSTGRES_PASSWORD: change-me
volumes:
- postgres_data:/var/lib/postgresql/data
pgbouncer:
image: edoburu/pgbouncer:1.24.1-p1
depends_on:
- postgres
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: app
DB_PASSWORD: change-me
DB_NAME: appdb
POOL_MODE: session
MAX_CLIENT_CONN: 200
DEFAULT_POOL_SIZE: 20
ports:
- "6432:5432"
Bring up PgBouncer without touching the app yet:
docker compose up -d pgbouncer
docker compose logs -f pgbouncer
Keep the first rollout simple. One app database, one PgBouncer service, one explicit pool mode.
Step 4: Switch the app carefully
Change only the app's database host and port so the rollback is obvious. For example:
DATABASE_URL=postgres://app:change-me@pgbouncer:5432/appdb
Then restart only the app services that need the new path:
docker compose up -d app worker scheduler
Run one smoke test from the app side:
docker compose exec app ./your-app-healthcheck-or-db-test-command
If you also run migrations, execute them through the same path only after the basic app connectivity test succeeds.
Step 5: Verify behavior and limits
Check these things immediately after cutover:
- The app can start, serve requests, and perform writes.
- Background jobs still connect successfully.
- No feature fails because of session state assumptions.
docker compose exec pgbouncer sh -c 'printf "SHOW POOLS;\nSHOW STATS;\n" | psql -h 127.0.0.1 -p 5432 -U postgres pgbouncer'
Expected outcome: client counts rise and fall, but server connections stay bounded near your configured pool size instead of exploding under burst traffic.
Rollback and troubleshooting
The app connects but features behave strangely.
That often points to pooling-mode incompatibility. Revert to direct Postgres or keep PgBouncer but stay on session pooling.
Migrations fail only through PgBouncer.
Run them directly against Postgres if the tool expects session-level behavior or advisory-lock semantics that are sensitive to pooling.
You need to roll back cleanly.
Point the app back to postgres:5432, restart the app services, and keep PgBouncer stopped until you understand the failure.
docker compose stop pgbouncer
docker compose up -d app worker scheduler