How to Set Up a Load-Balanced PostgreSQL Cluster with Patroni on RHEL 7
Running a single PostgreSQL instance is a liability in any production environment — it is a single point of failure that, when it goes down, takes your entire application with it. Patroni is an open-source, Python-based high-availability solution for PostgreSQL that uses a Distributed Configuration Store (DCS) — such as etcd, Consul, or ZooKeeper — to coordinate a leader election among PostgreSQL nodes. When the primary (leader) node fails, Patroni detects the outage through the DCS heartbeat, promotes the most up-to-date standby automatically, and updates the cluster state so that HAProxy (or any other load balancer) redirects writes to the new primary within seconds. This guide walks through installing and configuring a two-node Patroni cluster backed by etcd on RHEL 7, with HAProxy providing read/write split for application clients.
Prerequisites
- At least two RHEL 7 servers for PostgreSQL nodes (e.g.,
pg1: 192.168.1.11,pg2: 192.168.1.12) - A third server or the same nodes for etcd (e.g.,
192.168.1.10) - A fourth server or virtual IP for HAProxy (e.g.,
192.168.1.20) - RHEL 7 with EPEL and SCL (Software Collections) repositories enabled
- Root or sudo access on all nodes
- All nodes can reach each other on the required ports (2379, 2380 for etcd; 5432 for PostgreSQL; 8008 for Patroni REST API)
Step 1: Install etcd as the Distributed Configuration Store
Install and configure etcd on the dedicated DCS node (or as a cluster). For this guide, a single etcd node is used for simplicity. In production, run a three-node etcd cluster for quorum-based fault tolerance.
# On the etcd node (192.168.1.10)
sudo yum install -y epel-release
sudo yum install -y etcd
# Configure etcd
sudo tee /etc/etcd/etcd.conf <<'EOF'
ETCD_NAME=etcd0
ETCD_DATA_DIR=/var/lib/etcd/default.etcd
ETCD_LISTEN_CLIENT_URLS="http://192.168.1.10:2379,http://127.0.0.1:2379"
ETCD_ADVERTISE_CLIENT_URLS="http://192.168.1.10:2379"
ETCD_LISTEN_PEER_URLS="http://192.168.1.10:2380"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://192.168.1.10:2380"
ETCD_INITIAL_CLUSTER="etcd0=http://192.168.1.10:2380"
ETCD_INITIAL_CLUSTER_TOKEN="patroni-etcd-cluster"
ETCD_INITIAL_CLUSTER_STATE="new"
EOF
sudo systemctl enable etcd
sudo systemctl start etcd
# Verify etcd is healthy
etcdctl --endpoints=http://192.168.1.10:2379 cluster-health
# Output: cluster is healthy
Step 2: Install Python 3 and PostgreSQL on the Database Nodes
Patroni requires Python 3. On RHEL 7, Python 3 is available via the Software Collections Library (SCL). PostgreSQL 12 or 14 is available via the PostgreSQL YUM repository.
# On BOTH pg1 and pg2 nodes — run all commands on each
# Install Python 3 via SCL
sudo yum install -y centos-release-scl 2>/dev/null ||
sudo yum install -y scl-utils
sudo yum install -y rh-python36 rh-python36-python-devel
rh-python36-python-pip
# Make Python 3 available as 'python3' in the current shell
scl enable rh-python36 bash
# Add PostgreSQL YUM repository
sudo yum install -y
https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm
# Install PostgreSQL 14
sudo yum install -y postgresql14-server postgresql14-contrib postgresql14
# Add PostgreSQL binaries to PATH
echo 'export PATH=$PATH:/usr/pgsql-14/bin' |
sudo tee /etc/profile.d/pgsql.sh
source /etc/profile.d/pgsql.sh
# Do NOT initdb — Patroni will handle cluster initialization
# Verify psql is accessible
psql --version
Step 3: Install Patroni
# On BOTH pg1 and pg2 nodes
# Install Patroni with etcd support using pip3
source /opt/rh/rh-python36/enable
pip3 install --upgrade pip
pip3 install patroni[etcd]
# Verify installation
patroni --version
# Output: patroni 3.x.x
# Install psycopg2 (PostgreSQL Python adapter, required by Patroni)
pip3 install psycopg2-binary
# Create directories
sudo mkdir -p /etc/patroni
sudo mkdir -p /data/patroni
sudo useradd --system --home /var/lib/pgsql
--shell /bin/bash postgres 2>/dev/null || true
sudo chown postgres:postgres /data/patroni
sudo chmod 700 /data/patroni
Step 4: Configure Patroni
Each node needs its own patroni.yml with node-specific values for name, connect_address, and listen. The configuration below is for pg1; adjust IP addresses for pg2.
# /etc/patroni/patroni.yml — on pg1 (192.168.1.11)
sudo tee /etc/patroni/patroni.yml <<'EOF'
scope: postgres-cluster
namespace: /service/
name: pg1
restapi:
listen: 192.168.1.11:8008
connect_address: 192.168.1.11:8008
etcd:
hosts: 192.168.1.10:2379
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
postgresql:
use_pg_rewind: true
parameters:
wal_level: logical
hot_standby: "on"
max_connections: 200
max_wal_senders: 5
max_replication_slots: 5
wal_keep_segments: 8
initdb:
- encoding: UTF8
- data-checksums
pg_hba:
- host replication replicator 192.168.1.0/24 md5
- host all all 0.0.0.0/0 md5
users:
admin:
password: "AdminPassword1!"
options:
- createrole
- createdb
postgresql:
listen: 192.168.1.11:5432
connect_address: 192.168.1.11:5432
data_dir: /data/patroni
bin_dir: /usr/pgsql-14/bin
pgpass: /tmp/pgpass
authentication:
replication:
username: replicator
password: "ReplicatorPassword1!"
superuser:
username: postgres
password: "PostgresPassword1!"
rewind:
username: rewind_user
password: "RewindPassword1!"
parameters:
unix_socket_directories: '/var/run/postgresql'
tags:
nofailover: false
noloadbalance: false
clonefrom: false
nosync: false
EOF
sudo chown postgres:postgres /etc/patroni/patroni.yml
sudo chmod 600 /etc/patroni/patroni.yml
On pg2, create the same file but set name: pg2, restapi.listen: 192.168.1.12:8008, restapi.connect_address: 192.168.1.12:8008, postgresql.listen: 192.168.1.12:5432, and postgresql.connect_address: 192.168.1.12:5432.
Step 5: Create and Start the Patroni systemd Service
# On BOTH pg1 and pg2
sudo tee /etc/systemd/system/patroni.service <<'EOF'
[Unit]
Description=Patroni PostgreSQL High Availability
After=syslog.target network.target
[Service]
Type=simple
User=postgres
Group=postgres
# Load Python 3 SCL environment
Environment=PATH=/opt/rh/rh-python36/root/usr/bin:/usr/pgsql-14/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/opt/rh/rh-python36/root/usr/bin/patroni
/etc/patroni/patroni.yml
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=process
TimeoutStopSec=30
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable patroni
# Start pg1 FIRST (it will initdb and become the primary)
# On pg1:
sudo systemctl start patroni
sudo systemctl status patroni
# Wait ~30 seconds for pg1 to initialize, then start pg2
# On pg2:
sudo systemctl start patroni
sudo systemctl status patroni
Step 6: Verify the Cluster with patronictl
# Check cluster status from either node
source /opt/rh/rh-python36/enable
patronictl -c /etc/patroni/patroni.yml list
# Output:
# + Cluster: postgres-cluster (xxxxxxxxxxxxxxxx) +----+-----------+
# | Member | Host | Role | State | TL | Lag in MB |
# +--------+----------------+---------+---------+----+-----------+
# | pg1 | 192.168.1.11:5432 | Leader | running | 1 | |
# | pg2 | 192.168.1.12:5432 | Replica | running | 1 | 0 |
# +--------+----------------+---------+---------+----+-----------+
# Test a manual switchover (promotes pg2, demotes pg1)
patronictl -c /etc/patroni/patroni.yml switchover
postgres-cluster --master pg1 --candidate pg2 --force
# Verify the new leader
patronictl -c /etc/patroni/patroni.yml list
Step 7: Configure HAProxy for Read/Write Split
HAProxy uses Patroni’s REST health endpoint to determine which node is the primary (for writes) and which nodes are replicas (for reads). The primary returns HTTP 200 on /primary and replicas return HTTP 200 on /replica.
# On the HAProxy node (192.168.1.20)
sudo yum install -y haproxy
sudo tee /etc/haproxy/haproxy.cfg <<'EOF'
global
maxconn 100
log /dev/log local0
defaults
log global
mode tcp
retries 2
timeout connect 4s
timeout client 30m
timeout server 30m
timeout check 5s
option tcplog
# Primary (read/write) — only the Patroni leader responds 200 to /primary
listen postgres-primary
bind *:5000
option httpchk GET /primary
http-check expect status 200
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
server pg1 192.168.1.11:5432 check port 8008
server pg2 192.168.1.12:5432 check port 8008
# Replicas (read-only) — healthy replicas respond 200 to /replica
listen postgres-replicas
bind *:5001
balance roundrobin
option httpchk GET /replica
http-check expect status 200
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
server pg1 192.168.1.11:5432 check port 8008
server pg2 192.168.1.12:5432 check port 8008
# HAProxy stats page
listen stats
bind *:7000
mode http
stats enable
stats uri /
stats refresh 10s
EOF
sudo systemctl enable haproxy
sudo systemctl start haproxy
Applications that need read/write access connect to 192.168.1.20:5000. Read-only replicas (analytics, reporting) connect to 192.168.1.20:5001. HAProxy polls the Patroni REST API every 3 seconds, so failover is detected and traffic is rerouted within a few seconds of the primary going offline.
Step 8: Testing Automatic Failover
# Open a psql session through the HAProxy primary port
psql -h 192.168.1.20 -p 5000 -U postgres -c "SELECT pg_is_in_recovery();"
# Output: f (false = this is the primary)
# Simulate a primary failure by stopping Patroni on pg1
# On pg1:
sudo systemctl stop patroni
# Back on the HAProxy node, watch the cluster recover
# (allow ~15-30 seconds for leader election)
patronictl -c /etc/patroni/patroni.yml list
# Re-test the connection through HAProxy — pg2 should now be primary
psql -h 192.168.1.20 -p 5000 -U postgres -c "SELECT pg_is_in_recovery();"
# Output: f (pg2 is now the leader)
# Restart pg1 — it will rejoin as a replica automatically
# On pg1:
sudo systemctl start patroni
patronictl -c /etc/patroni/patroni.yml list
A Patroni-managed PostgreSQL cluster on RHEL 7 eliminates the manual intervention that single-instance PostgreSQL setups demand during failures. By combining etcd for distributed leader election, Patroni for automated failover and cluster state management, and HAProxy for transparent read/write routing, you achieve a high-availability database tier that can survive node failures with minimal downtime and zero application code changes. As your requirements grow, extend this architecture by adding a third PostgreSQL node for a synchronous replica, deploying a three-node etcd cluster for quorum resilience, and using pg_rewind (already enabled in this configuration) to quickly reintegrate a former primary that rejoins after a network partition. The patronictl command gives you full cluster visibility and safe manual switchover capabilities that make maintenance windows straightforward and predictable.