Deploy PgBouncer in front of PostgreSQL 17 with session, transaction and statement pooling modes, SCRAM-SHA-256 authentication, keepalived-based HA, and Prometheus monitoring for production workloads.
Prerequisites
- Root or sudo access on all nodes
- An existing PostgreSQL 17 instance with streaming replication configured
- Two or more Linux hosts for PgBouncer/HAProxy redundancy
- Basic familiarity with PostgreSQL authentication and networking
What this solves
PostgreSQL forks a new backend process for every client connection, which becomes expensive under high concurrency. PgBouncer sits between your application and PostgreSQL 17, multiplexing thousands of client connections onto a small pool of real database connections.
This tutorial covers pooling modes, SCRAM-SHA-256 authentication, running multiple PgBouncer instances behind keepalived and HAProxy for failover, tuning pool sizes for production, and monitoring with SHOW POOLS, SHOW STATS and a Prometheus exporter.
Step-by-step configuration
Install PostgreSQL 17 client libraries and PgBouncer
PgBouncer needs the PostgreSQL client libraries to connect upstream. Install both PgBouncer and the PostgreSQL 17 client tools before configuring anything.
sudo apt update
sudo apt install -y curl ca-certificates gnupg
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /usr/share/keyrings/postgresql.gpg
echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
sudo apt update
sudo apt install -y postgresql-client-17 pgbouncersudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
sudo dnf -qy module disable postgresql
sudo dnf install -y postgresql17 pgbouncerConfirm PostgreSQL 17 is reachable
Assume PostgreSQL 17 is already running on a primary server at 203.0.113.10. Verify connectivity before wiring up the pooler, otherwise you will debug PgBouncer for a problem that lives elsewhere.
psql -h 203.0.113.10 -U postgres -d appdb -c "SELECT version();"If you have not yet configured the primary, see install and configure PostgreSQL 17 with performance tuning and security hardening first.
Choose a pooling mode in pgbouncer.ini
PgBouncer supports three pooling modes. Session pooling assigns one server connection per client for the whole session, the safest but least efficient. Transaction pooling releases the server connection after each transaction, the best default for most web apps. Statement pooling releases it after every statement, but breaks multi-statement transactions and most ORMs, so use it only for stateless read-only workloads.
[databases]
appdb = host=203.0.113.10 port=5432 dbname=appdb
appdb_session = host=203.0.113.10 port=5432 dbname=appdb pool_mode=session
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = pgbouncer_admin
stats_users = pgbouncer_stats
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 10
reserve_pool_timeout = 3
max_db_connections = 100
max_user_connections = 100
server_idle_timeout = 300
server_lifetime = 3600
query_wait_timeout = 30
client_idle_timeout = 0
client_login_timeout = 60
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
pidfile = /var/run/pgbouncer/pgbouncer.pid
logfile = /var/log/pgbouncer/pgbouncer.logGenerate SCRAM-SHA-256 credentials in userlist.txt
PostgreSQL 17 defaults to scram-sha-256 password hashing. PgBouncer must use the exact same hash format in userlist.txt, plaintext or md5 entries will fail authentication against a SCRAM-only server.
psql -h 203.0.113.10 -U postgres -d appdb -t -A -c "SELECT rolname, rolpassword FROM pg_authid WHERE rolname = 'app_user';"Copy the output into userlist.txt, keeping the SCRAM-SHA-256 hash exactly as returned. Never write a real password here in plaintext.
"app_user" "SCRAM-SHA-256$4096:base64salt$base64storedkey:base64serverkey"
"pgbouncer_admin" "SCRAM-SHA-256$4096:base64salt$base64storedkey:base64serverkey"sudo chown pgbouncer:pgbouncer /etc/pgbouncer/userlist.txt /etc/pgbouncer/pgbouncer.ini
sudo chmod 600 /etc/pgbouncer/userlist.txtEnable and start PgBouncer
Start the service and confirm it is listening on port 6432, the standard PgBouncer port distinct from PostgreSQL's 5432.
sudo mkdir -p /var/log/pgbouncer /var/run/pgbouncer
sudo chown pgbouncer:pgbouncer /var/log/pgbouncer /var/run/pgbouncer
sudo systemctl enable --now pgbouncer
sudo systemctl status pgbouncerDeploy PgBouncer on a second node for redundancy
A single PgBouncer instance is a single point of failure. Repeat the installation and configuration steps above on a second host (203.0.113.11), pointing to the same PostgreSQL primary and using an identical userlist.txt and pgbouncer.ini.
scp /etc/pgbouncer/userlist.txt root@203.0.113.11:/etc/pgbouncer/userlist.txt
scp /etc/pgbouncer/pgbouncer.ini root@203.0.113.11:/etc/pgbouncer/pgbouncer.iniFront both PgBouncer nodes with HAProxy
HAProxy load balances client connections across both PgBouncer nodes and removes a failed node from rotation automatically using TCP health checks.
sudo apt install -y haproxysudo dnf install -y haproxyfrontend pgbouncer_front
bind 203.0.113.20:6432
mode tcp
default_backend pgbouncer_nodes
backend pgbouncer_nodes
mode tcp
balance roundrobin
option tcp-check
tcp-check connect port 6432
server pgb1 203.0.113.10:6432 check inter 3s fall 3 rise 2
server pgb2 203.0.113.11:6432 check inter 3s fall 3 rise 2sudo systemctl enable --now haproxyFor a deeper walkthrough of HAProxy tuning and ACL-based routing, see configure HAProxy load balancing with multiple backend servers.
Add keepalived for a floating virtual IP
HAProxy itself needs redundancy. Run keepalived on both HAProxy hosts so a virtual IP fails over automatically if the active node goes down.
sudo apt install -y keepalivedsudo dnf install -y keepalivedvrrp_script chk_haproxy {
script "/usr/bin/killall -0 haproxy"
interval 2
weight 2
}
vrrp_instance VI_PGBOUNCER {
state MASTER
interface eth0
virtual_router_id 51
priority 150
advert_int 1
authentication {
auth_type PASS
auth_pass Ch4ngeThisVrrpSecret
}
virtual_ipaddress {
203.0.113.20/24
}
track_script {
chk_haproxy
}
}sudo systemctl enable --now keepalivedSet state BACKUP and priority 100 on the second node. For the complete active/passive pattern including firewall rules, see configure keepalived with HAProxy backend health monitoring.
Tune pool sizes and timeouts for production load
default_pool_size controls how many server connections PgBouncer opens per database/user pair in transaction mode. Set it based on PostgreSQL's max_connections divided by the number of PgBouncer instances and databases sharing the primary, leaving headroom for replication and admin connections.
| Setting | Recommended starting value | Why |
|---|---|---|
| default_pool_size | 20-40 | Matches typical CPU core count on the database host |
| max_client_conn | 1000-5000 | PgBouncer connections are cheap, size for peak app instances |
| reserve_pool_size | 10-20% of default_pool_size | Absorbs short traffic bursts without queuing |
| server_idle_timeout | 300s | Frees idle backend connections during low traffic |
| query_wait_timeout | 30s | Fails fast instead of queueing indefinitely under saturation |
Verify PostgreSQL's own limit accommodates all pooled connections plus replication slots.
psql -h 203.0.113.10 -U postgres -c "SHOW max_connections;"Monitoring PgBouncer
Query live pool and stats via the admin console
PgBouncer exposes an administrative pseudo-database called pgbouncer. Connect as an admin_users entry to inspect pool state in real time.
psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin pgbouncer -c "SHOW POOLS;"
psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin pgbouncer -c "SHOW STATS;"
psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin pgbouncer -c "SHOW CLIENTS;"SHOW POOLS reports cl_active, cl_waiting and sv_active columns per database/user pair. A consistently non-zero cl_waiting means default_pool_size is too small for the current load.
Deploy the Prometheus exporter
prometheus-pgbouncer-exporter scrapes SHOW STATS and SHOW POOLS and exposes them as Prometheus metrics for Grafana dashboards and alerting.
sudo useradd --no-create-home --shell /usr/sbin/nologin pgbouncer_exporter
curl -L -o /tmp/pgbouncer_exporter.tar.gz https://github.com/prometheus-community/pgbouncer_exporter/releases/download/v0.10.2/pgbouncer_exporter-0.10.2.linux-amd64.tar.gz
tar -xzf /tmp/pgbouncer_exporter.tar.gz -C /tmp
sudo mv /tmp/pgbouncer_exporter-0.10.2.linux-amd64/pgbouncer_exporter /usr/local/bin/
sudo chown pgbouncer_exporter:pgbouncer_exporter /usr/local/bin/pgbouncer_exporter[Unit]
Description=PgBouncer Prometheus Exporter
After=network.target
[Service]
User=pgbouncer_exporter
Group=pgbouncer_exporter
Environment=DATA_SOURCE_NAME="postgres://pgbouncer_stats:StrongExporterPass9!@127.0.0.1:6432/pgbouncer?sslmode=disable"
ExecStart=/usr/local/bin/pgbouncer_exporter --web.listen-address=127.0.0.1:9127
Restart=on-failure
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now pgbouncer_exporter
curl http://127.0.0.1:9127/metrics | grep pgbouncer_poolsWire this into an existing Prometheus and Grafana stack as described in set up Prometheus and Grafana monitoring stack with Docker Compose, then build alerts on pgbouncer_pools_cl_waiting and pgbouncer_pools_sv_used.
Failover testing with streaming replication
Simulate a primary failure
If PostgreSQL streaming replication is already in place, test that PgBouncer's target can be repointed to a promoted replica quickly. This is the scenario your HA setup exists for.
sudo systemctl stop postgresqlOn the standby, promote it to become the new primary.
sudo -u postgres /usr/lib/postgresql/17/bin/pg_ctl promote -D /var/lib/postgresql/17/mainRepoint PgBouncer to the new primary
Update the host in pgbouncer.ini on both PgBouncer nodes, then reload without restarting, PgBouncer reload keeps existing pooled connections draining gracefully.
[databases]
appdb = host=203.0.113.11 port=5432 dbname=appdbsudo systemctl reload pgbouncerFor the full replication topology, promotion tooling, and automated failover scripts, follow set up PostgreSQL 17 streaming replication with PgBouncer connection pooling and load balancing.
Verify your setup
sudo systemctl status pgbouncer haproxy keepalived
psql -h 203.0.113.20 -p 6432 -U app_user -d appdb -c "SELECT 1;"
psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin pgbouncer -c "SHOW POOLS;"
curl -s http://127.0.0.1:9127/metrics | grep pgbouncer_upCommon issues
| Symptom | Cause | Fix |
|---|---|---|
| ERROR: password authentication failed | userlist.txt has a plaintext or md5 hash but PostgreSQL uses scram-sha-256 | Regenerate the entry with the exact rolpassword value from pg_authid |
| Clients hang waiting for a connection | default_pool_size too small for concurrent load | Check SHOW POOLS for cl_waiting, raise default_pool_size or reserve_pool_size |
| Application sees stale prepared statements after failover | Statement pooling mode used with a stateful ORM | Switch that database entry to pool_mode=session or transaction |
| Virtual IP does not move on failover | Firewall blocks VRRP multicast traffic between HAProxy nodes | Allow protocol 112 (VRRP) between node IPs instead of disabling the firewall |
| Prometheus exporter shows no metrics | pgbouncer_stats user lacks permission or wrong DSN | Confirm stats_users includes the exporter's role in pgbouncer.ini |
| PostgreSQL rejects connections after reload | max_connections exceeded by combined pool sizes across instances | Reduce default_pool_size or raise max_connections and restart PostgreSQL |
Next steps
- Set up PostgreSQL 17 streaming replication with PgBouncer connection pooling and load balancing
- Configure PostgreSQL 17 SSL encryption and advanced security hardening
- Monitor PostgreSQL performance with Prometheus and Grafana dashboards
- Configure PostgreSQL 17 PgBouncer multi-region load balancing
- Automate PostgreSQL failover with Patroni and etcd
Running this in production?
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# ---------------------------------------------------------------------------
# PgBouncer + PostgreSQL 17 connection pooling installer
# Usage: sudo ./install-pgbouncer.sh <primary_pg_host> <db_name> <db_user> [pg_port]
# ---------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}$*${NC}"; }
warn() { echo -e "${YELLOW}$*${NC}"; }
err() { echo -e "${RED}$*${NC}" >&2; }
usage() {
echo "Usage: sudo $0 <primary_pg_host> <db_name> <db_user> [pg_port]"
echo "Example: sudo $0 203.0.113.10 appdb app_user 5432"
exit 1
}
[ "$#" -lt 3 ] && usage
PG_HOST="$1"
DB_NAME="$2"
DB_USER="$3"
PG_PORT="${4:-5432}"
PGB_CONF_DIR="/etc/pgbouncer"
PGB_LISTEN_PORT="6432"
STATE_FILE="/tmp/pgbouncer_install_state"
# --- Prerequisite checks ----------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
err "This script must be run as root or with sudo."
exit 1
fi
for cmd in curl gpg; do
command -v "$cmd" >/dev/null 2>&1 || warn "$cmd not found yet, will attempt install."
done
# --- Distro detection --------------------------------------------------------
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian) PKG_MGR="apt"; PKG_INSTALL="apt install -y" ;;
almalinux|rocky|centos|rhel|ol|fedora) PKG_MGR="dnf"; PKG_INSTALL="dnf install -y" ;;
amzn) PKG_MGR="yum"; PKG_INSTALL="yum install -y" ;;
*) err "Unsupported distro: $ID"; exit 1 ;;
esac
else
err "/etc/os-release not found. Cannot detect distro."
exit 1
fi
# --- Rollback on failure -----------------------------------------------------
cleanup_on_error() {
err "Installation failed. Rolling back changes..."
systemctl stop pgbouncer 2>/dev/null || true
if [ -f "$STATE_FILE" ]; then
rm -f "$STATE_FILE"
fi
warn "Partial config left at ${PGB_CONF_DIR} for inspection. Remove manually if needed."
exit 1
}
trap cleanup_on_error ERR
TOTAL_STEPS=8
# --- Step 1: Install PostgreSQL 17 client + PgBouncer -----------------------
echo "[1/${TOTAL_STEPS}] Installing PostgreSQL 17 client tools and PgBouncer..."
case "$PKG_MGR" in
apt)
apt update
$PKG_INSTALL curl ca-certificates gnupg lsb-release
install -d -m 755 /usr/share/keyrings
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg
echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
apt update
$PKG_INSTALL postgresql-client-17 pgbouncer
PGB_SERVICE="pgbouncer"
;;
dnf|yum)
if ! rpm -q pgdg-redhat-repo >/dev/null 2>&1; then
$PKG_INSTALL https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
fi
dnf -qy module disable postgresql || true
$PKG_INSTALL postgresql17 pgbouncer
PGB_SERVICE="pgbouncer"
;;
esac
# --- Step 2: Verify connectivity to primary ---------------------------------
echo "[2/${TOTAL_STEPS}] Verifying connectivity to PostgreSQL primary at ${PG_HOST}:${PG_PORT}..."
if ! PGPASSWORD="${PGPASSWORD:-}" psql -h "$PG_HOST" -p "$PG_PORT" -U postgres -d "$DB_NAME" -c "SELECT version();" >/dev/null 2>&1; then
warn "Could not verify connection as 'postgres'. Continuing, but confirm the primary is reachable manually:"
warn " psql -h $PG_HOST -p $PG_PORT -U postgres -d $DB_NAME -c 'SELECT version();'"
else
log "Primary is reachable."
fi
# --- Step 3: Prepare directories and ownership -------------------------------
echo "[3/${TOTAL_STEPS}] Preparing PgBouncer directories..."
id pgbouncer >/dev/null 2>&1 || { err "pgbouncer system user not found after install."; exit 1; }
install -d -m 755 -o pgbouncer -g pgbouncer "$PGB_CONF_DIR"
install -d -m 755 -o pgbouncer -g pgbouncer /var/log/pgbouncer
install -d -m 755 -o pgbouncer -g pgbouncer /var/run/pgbouncer
# --- Step 4: Write pgbouncer.ini ---------------------------------------------
echo "[4/${TOTAL_STEPS}] Writing ${PGB_CONF_DIR}/pgbouncer.ini..."
cat > "${PGB_CONF_DIR}/pgbouncer.ini" <<EOF
[databases]
${DB_NAME} = host=${PG_HOST} port=${PG_PORT} dbname=${DB_NAME}
${DB_NAME}_session = host=${PG_HOST} port=${PG_PORT} dbname=${DB_NAME} pool_mode=session
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = ${PGB_LISTEN_PORT}
auth_type = scram-sha-256
auth_file = ${PGB_CONF_DIR}/userlist.txt
admin_users = pgbouncer_admin
stats_users = pgbouncer_stats
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 10
reserve_pool_timeout = 3
max_db_connections = 100
max_user_connections = 100
server_idle_timeout = 300
server_lifetime = 3600
query_wait_timeout = 30
client_idle_timeout = 0
client_login_timeout = 60
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
pidfile = /var/run/pgbouncer/pgbouncer.pid
logfile = /var/log/pgbouncer/pgbouncer.log
EOF
chown pgbouncer:pgbouncer "${PGB_CONF_DIR}/pgbouncer.ini"
chmod 644 "${PGB_CONF_DIR}/pgbouncer.ini"
# --- Step 5: Fetch SCRAM credentials and build userlist.txt ------------------
echo "[5/${TOTAL_STEPS}] Fetching SCRAM-SHA-256 credentials for ${DB_USER}..."
touch "${PGB_CONF_DIR}/userlist.txt"
chown pgbouncer:pgbouncer "${PGB_CONF_DIR}/userlist.txt"
chmod 600 "${PGB_CONF_DIR}/userlist.txt"
ROLE_HASH=$(psql -h "$PG_HOST" -p "$PG_PORT" -U postgres -d "$DB_NAME" -t -A \
-c "SELECT rolpassword FROM pg_authid WHERE rolname = '${DB_USER}';" 2>/dev/null || true)
if [ -z "$ROLE_HASH" ] || [[ "$ROLE_HASH" != SCRAM-SHA-256* ]]; then
warn "Could not fetch a SCRAM-SHA-256 hash automatically for '${DB_USER}'."
warn "Populate ${PGB_CONF_DIR}/userlist.txt manually with the exact hash from pg_authid."
else
printf '"%s" "%s"\n' "$DB_USER" "$ROLE_HASH" > "${PGB_CONF_DIR}/userlist.txt"
chown pgbouncer:pgbouncer "${PGB_CONF_DIR}/userlist.txt"
chmod 600 "${PGB_CONF_DIR}/userlist.txt"
log "userlist.txt populated for ${DB_USER}."
fi
warn "Add pgbouncer_admin / pgbouncer_stats entries to userlist.txt manually if needed."
# --- Step 6: Firewall configuration ------------------------------------------
echo "[6/${TOTAL_STEPS}] Configuring firewall for port ${PGB_LISTEN_PORT}..."
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
ufw allow "${PGB_LISTEN_PORT}/tcp" || true
elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
firewall-cmd --permanent --add-port="${PGB_LISTEN_PORT}/tcp"
firewall-cmd --reload
else
warn "No active supported firewall detected; skipping firewall rule."
fi
# --- Step 7: Enable and start service ----------------------------------------
echo "[7/${TOTAL_STEPS}] Enabling and starting ${PGB_SERVICE}..."
systemctl enable "$PGB_SERVICE"
systemctl restart "$PGB_SERVICE"
touch "$STATE_FILE"
# --- Step 8: Verification ----------------------------------------------------
echo "[8/${TOTAL_STEPS}] Verifying PgBouncer is listening and responsive..."
sleep 2
if ! systemctl is-active --quiet "$PGB_SERVICE"; then
err "PgBouncer service is not active."
exit 1
fi
if ss -ltn 2>/dev/null | grep -q ":${PGB_LISTEN_PORT} "; then
log "PgBouncer is listening on port ${PGB_LISTEN_PORT}."
else
err "PgBouncer does not appear to be listening on port ${PGB_LISTEN_PORT}."
exit 1
fi
if psql -h 127.0.0.1 -p "$PGB_LISTEN_PORT" -U "$DB_USER" -
Review the script before running. Execute with: bash install.sh