PostgreSQL does not include built-in primary election or automatic failover — that is where Patroni comes in. Patroni is a Python-based template for HA PostgreSQL using a distributed configuration store (DCS) such as etcd to coordinate which node is the primary at any given time. When the primary fails, Patroni automatically elects a new primary from the available replicas with no manual intervention. This tutorial sets up a three-node PostgreSQL cluster managed by Patroni and etcd on RHEL 9, then puts HAProxy and PgBouncer in front to provide a single connection endpoint.
Prerequisites
- Three RHEL 9 nodes (this tutorial uses
pg1,pg2,pg3with IPs192.168.1.21–23) - PostgreSQL 15 installed on all nodes:
dnf install -y postgresql-server postgresql - etcd installed on all three nodes:
dnf install -y etcd - Python 3.9+ and pip available:
dnf install -y python3-pip - A fourth node or the first node available for HAProxy and PgBouncer
- All nodes resolvable by hostname or configured in
/etc/hosts
Step 1 — Bootstrap etcd on All Three Nodes
Edit /etc/etcd/etcd.conf on each node. Shown here is the config for pg1; adjust ETCD_NAME and ETCD_INITIAL_ADVERTISE_PEER_URLS for each node:
# /etc/etcd/etcd.conf on pg1
ETCD_NAME="pg1"
ETCD_DATA_DIR="/var/lib/etcd/default.etcd"
ETCD_LISTEN_PEER_URLS="http://192.168.1.21:2380"
ETCD_LISTEN_CLIENT_URLS="http://192.168.1.21:2379,http://127.0.0.1:2379"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://192.168.1.21:2380"
ETCD_ADVERTISE_CLIENT_URLS="http://192.168.1.21:2379"
ETCD_INITIAL_CLUSTER="pg1=http://192.168.1.21:2380,pg2=http://192.168.1.22:2380,pg3=http://192.168.1.23:2380"
ETCD_INITIAL_CLUSTER_STATE="new"
ETCD_INITIAL_CLUSTER_TOKEN="pg-cluster-token"
# Start etcd on all three nodes
systemctl enable --now etcd
# Verify cluster health from pg1
etcdctl --endpoints=http://192.168.1.21:2379 member list
etcdctl --endpoints=http://192.168.1.21:2379 endpoint health
Step 2 — Install Patroni on All Three Nodes
# Install Patroni with etcd support
pip3 install patroni[etcd] psycopg2-binary
# Create configuration directory and PostgreSQL data directory
mkdir -p /etc/patroni
mkdir -p /data/patroni
chown postgres:postgres /data/patroni
chmod 700 /data/patroni
Step 3 — Configure Patroni on Each Node
Create /etc/patroni/patroni.yml. The name and connect_address must be unique per node. Shown here is pg1:
# /etc/patroni/patroni.yml on pg1
scope: pg-cluster
namespace: /db/
name: pg1
etcd:
hosts: 192.168.1.21:2379,192.168.1.22:2379,192.168.1.23:2379
restapi:
listen: 192.168.1.21:8008
connect_address: 192.168.1.21:8008
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576 # 1 MB
postgresql:
use_pg_rewind: true
use_slots: true
parameters:
wal_level: replica
hot_standby: "on"
max_wal_senders: 5
max_replication_slots: 5
initdb:
- encoding: UTF8
- data-checksums
postgresql:
listen: 192.168.1.21:5432
connect_address: 192.168.1.21:5432
data_dir: /data/patroni
bin_dir: /usr/bin
pgpass: /tmp/pgpass
authentication:
replication:
username: replicator
password: RepPass123!
superuser:
username: postgres
password: PgPass123!
parameters:
unix_socket_directories: '/var/run/postgresql'
tags:
nofailover: false
noloadbalance: false
clonefrom: false
nosync: false
On pg2 and pg3, copy this file and replace every occurrence of 192.168.1.21 with the node’s own IP, and change name: pg1 to pg2/pg3.
Step 4 — Create a Systemd Service and Start the Cluster
# Create systemd unit on all three nodes
cat > /etc/systemd/system/patroni.service << 'EOF'
[Unit]
Description=Patroni - High Availability PostgreSQL
After=syslog.target network.target
[Service]
Type=simple
User=postgres
Group=postgres
ExecStart=/usr/local/bin/patroni /etc/patroni/patroni.yml
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=process
TimeoutSec=30
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now patroni
# Start pg1 first, then pg2 and pg3
# pg1 will initialize as primary; pg2 and pg3 will clone and become replicas
# Check the cluster from any node
patronictl -c /etc/patroni/patroni.yml list
Expected output shows one node as Leader and two as Replica with a lag near zero.
Step 5 — Perform a Controlled Switchover
# Graceful switchover: moves the primary role to pg2
patronictl -c /etc/patroni/patroni.yml switchover pg-cluster
--master pg1 --candidate pg2 --scheduled now --force
# Verify the new topology
patronictl -c /etc/patroni/patroni.yml list
# Simulate a failover (kills the primary process to test automatic election)
patronictl -c /etc/patroni/patroni.yml failover pg-cluster --force
Step 6 — Add PgBouncer and HAProxy
PgBouncer reduces connection overhead; HAProxy routes write traffic to the current primary and read traffic to replicas. Install both on a dedicated node or on pg1:
# Install packages
dnf install -y pgbouncer haproxy
# Minimal /etc/pgbouncer/pgbouncer.ini
cat >> /etc/pgbouncer/pgbouncer.ini <> /etc/haproxy/haproxy.cfg << 'EOF'
frontend pg_primary
bind *:5000
default_backend pg_primary_backend
backend pg_primary_backend
option httpchk GET /primary
http-check expect status 200
server pg1 192.168.1.21:6432 check port 8008
server pg2 192.168.1.22:6432 check port 8008
server pg3 192.168.1.23:6432 check port 8008
frontend pg_replica
bind *:5001
default_backend pg_replica_backend
backend pg_replica_backend
balance roundrobin
option httpchk GET /replica
http-check expect status 200
server pg1 192.168.1.21:6432 check port 8008
server pg2 192.168.1.22:6432 check port 8008
server pg3 192.168.1.23:6432 check port 8008
EOF
systemctl enable --now haproxy
Patroni’s REST API answers GET /primary with HTTP 200 only on the current primary node and GET /replica with 200 only on replicas, so HAProxy’s health checks automatically route traffic to the correct node after any failover.
Conclusion
You now have a three-node PostgreSQL cluster on RHEL 9 with automatic primary election coordinated by Patroni and etcd, connection pooling via PgBouncer, and intelligent routing via HAProxy. A primary failure will trigger a new election within the ttl window (30 seconds by default), with no manual intervention required. Applications connect to HAProxy on port 5000 for writes and port 5001 for reads, and the connection string never needs to change after a failover.
Next steps: How to Enable Patroni Synchronous Replication for Zero-Data-Loss Failover on RHEL 9, How to Set Up Point-in-Time Recovery for PostgreSQL with pgBackRest on RHEL 9, and How to Monitor a Patroni PostgreSQL Cluster with Prometheus and Grafana on RHEL 9.