Deploying Highly Available PgBouncer
This guide is part of PgBouncer vs RDS Proxy vs pgpool-II. A single PgBouncer between an application fleet and PostgreSQL concentrates every connection through one process on one host. It works well until that host reboots, its process is restarted for a configuration change, or its single core saturates — and then every service that shares it fails at once:
psql: error: connection to server at "pgbouncer.internal" (10.2.0.14), port 6432 failed:
Connection refused
2026-09-16T03:14:02Z ERROR orders-api connect ECONNREFUSED 10.2.0.14:6432
2026-09-16T03:14:02Z ERROR billing-api connect ECONNREFUSED 10.2.0.14:6432
The objective is a deployment where no single PgBouncer instance is load-bearing, where the backend connection total stays the same no matter how many instances run, and where a failover or a configuration reload is invisible to applications.
Rapid incident diagnosis
Establish what failed. A proxy outage and a database outage look identical from the application, and the distinction decides the response:
# Is the proxy listening?
nc -zv pgbouncer.internal 6432
# Is the database reachable directly from the proxy host?
psql -h orders-db.internal -U ops -c "SELECT 1"
# Is the proxy process alive but saturated?
pidstat -C pgbouncer 1 3
Check the proxy’s own view. The admin console answers whether the proxy is healthy but full, versus broken:
SHOW POOLS; -- cl_waiting, sv_active, maxwait
SHOW DATABASES; -- pool_size, max_connections per database entry
SHOW LISTS; -- totals: databases, users, pools, free_clients, used_servers
Look for a single point of failure in the path. Even with several PgBouncer instances, a single virtual IP holder, a single DNS record with a long TTL, or a Service with one endpoint reproduces the problem. List the endpoints actually in rotation:
kubectl get endpoints pgbouncer -o jsonpath='{.subsets[*].addresses[*].ip}{"\n"}'
dig +short pgbouncer.internal
Check for a split backend budget. Several instances each with the configured default_pool_size multiply backend connections. If PostgreSQL shows more sessions than expected after adding an instance, the pool sizes were not divided.
Mathematical sizing or parameter formula
Divide the backend pool, not the client limit. Every instance opens its own backend connections, so the per-instance size is the fleet target divided by the instance count:
default_pool_size_per_instance = floor( intended_backend_total ÷ instances )
max_db_connections_per_instance = floor( backend_budget ÷ instances )
With an intended 60 backend connections and three instances, each gets 20. During a rolling replacement a fourth instance may exist briefly, so either budget for instances + 1 or replace instances one at a time:
worst_case_backends = (instances + 1) × default_pool_size_per_instance
Client limits do not need dividing. max_client_conn bounds clients per instance, and clients spread across instances, so each instance can keep the full value. Size it for the case where one instance is missing:
max_client_conn ≥ ceil( total_client_connections ÷ (instances − 1) ) × 1.5
With 900 client connections and three instances, each instance should accept at least ceil(900 ÷ 2) × 1.5 = 675; a round 1,000 is comfortable.
Instance count from throughput and availability. Availability wants at least two; throughput wants enough single-threaded processes to carry peak transactions:
instances ≥ max( 2, ceil( peak_tps ÷ per_process_tps_at_70pct ) )
At 40,000 transactions per second peak and 18,000 per process, three instances satisfy both.
Reserve capacity for a lost instance. With N instances sized so that N−1 can carry peak load, a failure is a latency blip rather than an outage:
per_instance_capacity ≥ peak_tps ÷ (instances − 1)
Exact remediation & configuration
On Kubernetes, run PgBouncer as a Deployment behind a Service. The Service spreads new connections across ready pods, and a pod restart affects only its own clients:
apiVersion: apps/v1
kind: Deployment
metadata:
name: pgbouncer
spec:
replicas: 3
strategy:
rollingUpdate: { maxSurge: 0, maxUnavailable: 1 } # never exceed the backend budget
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: pgbouncer
image: edoburu/pgbouncer:latest
ports: [{ containerPort: 6432 }]
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c 'PAUSE;' || true; sleep 15"]
readinessProbe:
tcpSocket: { port: 6432 }
periodSeconds: 5
livenessProbe:
exec:
command: ["/bin/sh", "-c", "psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c 'SHOW LISTS;' > /dev/null"]
periodSeconds: 15
failureThreshold: 3
maxSurge: 0 matters: a surge pod would add another full backend pool. Replacing one pod at a time keeps the backend total within budget.
Configuration, divided:
[databases]
orders = host=orders-db.internal port=5432 dbname=orders
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction
default_pool_size = 20 ; 60 intended ÷ 3 instances
reserve_pool_size = 3
max_db_connections = 22
max_client_conn = 1000 ; not divided
server_lifetime = 3600
server_idle_timeout = 600
query_wait_timeout = 15
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = pgbadmin
stats_users = pgbstats
On virtual machines, use keepalived to hold a virtual IP, with a check script that verifies PgBouncer actually answers rather than merely listening:
#!/bin/sh
# /etc/keepalived/check_pgbouncer.sh
psql -h 127.0.0.1 -p 6432 -U pgbstats pgbouncer -Atc "SHOW LISTS;" > /dev/null 2>&1
vrrp_script check_pgbouncer {
script "/etc/keepalived/check_pgbouncer.sh"
interval 3
fall 2
rise 2
}
vrrp_instance PGB {
state BACKUP
interface eth0
virtual_router_id 51
priority 100
advert_int 1
virtual_ipaddress { 10.2.0.50/24 }
track_script { check_pgbouncer }
}
Note that a virtual IP means only one instance serves traffic at a time: it provides availability but not throughput. Where both are needed, run several instances behind a TCP load balancer, or run several processes per host with so_reuseport, as described in Scaling PgBouncer Across CPU Cores with so_reuseport.
Reload configuration instead of restarting. RELOAD applies most settings without dropping client connections:
psql -h 127.0.0.1 -p 6432 -U pgbadmin pgbouncer -c "RELOAD;"
PAUSE and RESUME allow a graceful drain before a planned restart: PAUSE finishes in-flight transactions and holds new ones, so clients experience a brief delay rather than an error.
Making failover invisible to applications
Infrastructure redundancy is only half the job; the application must handle the moment of transition.
Client-side retry on connect. A connection error to one instance should be retried, because the next attempt will resolve to a healthy one. Most pools reconnect automatically, but the first query on a broken connection still fails, so idempotent operations should be retried at the request level:
const pool = new pg.Pool({
connectionString: "postgres://app@pgbouncer.internal:6432/orders",
max: 10,
connectionTimeoutMillis: 2000,
idleTimeoutMillis: 60000, // shed connections to a drained instance quickly
});
pool.on("error", (err) => log.warn({ err }, "idle client error; pool will reconnect"));
Short DNS TTLs and multiple addresses. If clients resolve a name rather than using a Service or virtual IP, the record should carry every instance’s address with a short TTL, and the client library must re-resolve rather than caching the first answer for the life of the process.
Application pool lifetimes spread reconnection. A maxLifetime of 20–30 minutes means client connections gradually redistribute across instances after one returns, instead of staying concentrated on the survivors indefinitely.
Test the transition, not just the redundancy. Killing an instance while traffic runs is the only way to know whether applications reconnect cleanly:
kubectl delete pod -l app=pgbouncer --field-selector spec.nodeName=node-3
Expect a brief burst of connection errors from clients that were attached to that pod, then recovery within seconds. Errors that persist mean something is caching the dead address.
| Layer | Mechanism | What it protects against |
|---|---|---|
| Several instances | Deployment or several hosts | Instance failure, restarts |
| Service, virtual IP or load balancer | Endpoint removal on failure | Sending clients to a dead instance |
| Readiness and liveness probes | Removal from rotation | A process that listens but does not answer |
maxSurge: 0, divided pools |
Budget arithmetic | Backend connection overrun during rollout |
| Application retry and lifetimes | Reconnect and redistribute | The moment of transition |
Validation & verification
Backend connections are unchanged by the instance count. After adding an instance, PostgreSQL’s session count from the proxy fleet must stay at the intended total:
SELECT client_addr, count(*) FROM pg_stat_activity
WHERE usename = 'orders_app' GROUP BY client_addr ORDER BY 2 DESC;
Three proxy addresses each holding about 20 sessions is the expected shape.
Losing an instance does not lose the service. With load running, delete or stop one instance. Client errors should last a second or two, and SHOW POOLS on the survivors should show their client counts rising to absorb the traffic.
A rollout stays inside the budget. Trigger a rolling update and sample the database’s session count every second; the peak must not exceed the intended total, which is what maxSurge: 0 guarantees.
Probes catch a hung process. Simulate one with PAUSE on an instance without RESUME; the readiness probe — if it runs a real query rather than a TCP check — should remove it from rotation. A TCP-only probe will not, which is why the liveness probe above runs SHOW LISTS.
Frequently Asked Questions
Should PgBouncer run as a sidecar instead?
Does a Service load balance existing connections?
Can the instances share state?
What happens to a transaction in flight when an instance dies?
Related
- PgBouncer vs RDS Proxy vs pgpool-II — the parent comparison of proxy options.
- Scaling PgBouncer Across CPU Cores with so_reuseport — throughput within one host.
- Running PgBouncer as a Kubernetes Sidecar — the per-pod alternative.
- Configuring PgBouncer Pool Size and max_client_conn — the limits this page divides.