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.

A single PgBouncer as a shared failure domain Several services connect through one PgBouncer host to PostgreSQL. A failure of that host removes database access for all of them simultaneously. orders-api billing-api reporting PgBouncer single host single core PostgreSQL a reboot, a reload or a saturated core takes every service with it
One proxy host concentrates every application connection. Its failure, its reboot and its single core are all shared by every service behind it.

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)
Dividing limits across PgBouncer instances Each of three instances takes a third of the intended sixty backend connections, while each keeps a client limit large enough to accept the entire client fleet if one instance is lost. Backend side: divide Client side: do not divide instance 1 · pool 20 instance 2 · pool 20 instance 3 · pool 20 60 backend connections total instance 1 · 1,000 instance 2 · 1,000 instance 3 · 1,000 900 clients fit on any two if the third instance dies, its clients reconnect and land on the survivors
Backend connections must be divided by the instance count, while client limits are sized so that the remaining instances can absorb the whole fleet.

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
Layers that make a PgBouncer failover invisible Several instances behind a Service, with probes removing unhealthy pods, divided pool sizes preserving the backend budget, and application retries covering the moment of transition. Instances redundancy Service + probes routing Divided pools budget kept Client retry transition All four, or the failover is visible the last one is the one most often missing, and the only one the application owns
Redundancy at the infrastructure layer only helps if the client reconnects. The transition is where an untested high-availability setup usually fails.

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?
A sidecar per application pod removes the shared failure domain entirely, at the cost of backend connections that scale with pod count. The trade-off is examined in Running PgBouncer as a Kubernetes Sidecar; the two designs suit different fleet sizes.
Does a Service load balance existing connections?
No. It balances new connections only. Long-lived client connections stay on the pod they landed on, which is why application-side connection lifetimes are what redistribute load after an instance returns.
Can the instances share state?
No — each has its own pools, its own statistics and its own client connections. That independence is what makes the design simple; the only shared state is the configuration, which should be deployed identically to all of them.
What happens to a transaction in flight when an instance dies?
It fails. The client sees a connection error, and the transaction is rolled back by PostgreSQL when it notices the disconnection. Applications must treat that as a retryable failure for idempotent work, and must not assume a commit that was not acknowledged.