A Patroni cluster needs an odd number of nodes to maintain quorum — with 3 nodes, losing 1 still leaves a majority, so the cluster keeps running. This guide builds a 3-node PostgreSQL cluster on Ubuntu 24.04 with Patroni handling replication and automatic failover, etcd as the coordination store, and HAProxy load-balancing client connections — all secured with TLS.
Prerequisites: three Ubuntu 24.04 servers (2 vCPU / 4GB RAM minimum) with PostgreSQL installed, non-root sudo access, and a domain with three A records:
node1.example.com,node2.example.com,node3.example.com. Replace these placeholders with your actual subdomains throughout.
Install Dependencies
Run on all three nodes unless noted otherwise.
1. Install packages:
$ sudo apt update
$ sudo apt install haproxy certbot pipx -y
$ sudo pip3 install --break-system-packages 'patroni[etcd3]' psycopg2-binary psycopg
2. Install etcd:
$ wget https://github.com/etcd-io/etcd/releases/download/v3.6.4/etcd-v3.6.4-linux-amd64.tar.gz
$ tar -xvf etcd-v3.6.4-linux-amd64.tar.gz
$ sudo mv etcd-v3.6.4-linux-amd64/etcd etcd-v3.6.4-linux-amd64/etcdctl /usr/local/bin/
3. Open firewall ports — 80 (Certbot), 2379/2380 (etcd), 5432/5433 (PostgreSQL + Patroni-managed PostgreSQL), 8008/8009 (Patroni REST API):
$ sudo ufw allow 80,2379,2380,5432,5433,8008,8009/tcp
$ sudo ufw reload
$ sudo ufw status
Configure SSL Certificates
1. Request a certificate per node (run on each node for its own subdomain):
$ sudo certbot certonly --standalone -d node1.example.com -m admin@example.com --agree-tos --no-eff
2. Create a cert-prep script on each node (set HOSTNAME to that node's subdomain):
$ sudo nano /usr/local/bin/prepare-ssl-certs.sh
#!/bin/bash
HOSTNAME="node1.example.com" # Update for each node
CERT_DIR="/etc/letsencrypt/live/$HOSTNAME"
ARCHIVE_DIR="/etc/letsencrypt/archive/$HOSTNAME"
getent group ssl-users >/dev/null || sudo groupadd ssl-users
for user in etcd patroni haproxy postgres; do
if ! id "$user" >/dev/null 2>&1 && [[ "$user" == "etcd" || "$user" == "patroni" ]]; then
getent group "$user" >/dev/null && sudo useradd -r -m -s /bin/bash -g "$user" "$user" || sudo useradd -r -m -s /bin/bash "$user"
fi
groups "$user" 2>/dev/null | grep -q ssl-users || sudo usermod -aG ssl-users "$user"
done
cat "$CERT_DIR/fullchain.pem" "$CERT_DIR/privkey.pem" > "$CERT_DIR/combined.pem"
sudo chmod 755 /etc/letsencrypt /etc/letsencrypt/live /etc/letsencrypt/archive
sudo chgrp ssl-users "$CERT_DIR" "$ARCHIVE_DIR"
sudo chmod 755 "$CERT_DIR" "$ARCHIVE_DIR"
sudo chown root:ssl-users "$ARCHIVE_DIR"/*.pem "$CERT_DIR/combined.pem"
sudo chmod 644 "$ARCHIVE_DIR"/cert*.pem "$ARCHIVE_DIR"/chain*.pem "$ARCHIVE_DIR"/fullchain*.pem
sudo chmod 640 "$ARCHIVE_DIR"/privkey*.pem "$CERT_DIR/combined.pem"
id "linuxuser" >/dev/null 2>&1 && ! groups linuxuser | grep -q ssl-users && sudo usermod -aG ssl-users linuxuser
echo "SSL certificate setup completed for $HOSTNAME"
$ sudo chmod +x /usr/local/bin/prepare-ssl-certs.sh
$ sudo /usr/local/bin/prepare-ssl-certs.sh
3. Create a renewal hook so certs stay combined and services reload after renewal:
$ sudo nano /usr/local/bin/prepare-renewed-ssl-certs.sh
#!/bin/bash
HOSTNAME="node1.example.com" # Update for each node
CERT_DIR="/etc/letsencrypt/live/$HOSTNAME"
ARCHIVE_DIR="/etc/letsencrypt/archive/$HOSTNAME"
cat "$CERT_DIR/fullchain.pem" "$CERT_DIR/privkey.pem" > "$CERT_DIR/combined.pem" 2>/dev/null
chmod 755 /etc/letsencrypt /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null
chgrp ssl-users "$CERT_DIR" "$ARCHIVE_DIR" 2>/dev/null
chmod 755 "$CERT_DIR" "$ARCHIVE_DIR" 2>/dev/null
chown root:ssl-users "$ARCHIVE_DIR"/*.pem "$CERT_DIR/combined.pem" 2>/dev/null
chmod 644 "$ARCHIVE_DIR"/cert*.pem "$ARCHIVE_DIR"/chain*.pem "$ARCHIVE_DIR"/fullchain*.pem 2>/dev/null
chmod 640 "$ARCHIVE_DIR"/privkey*.pem "$CERT_DIR/combined.pem" 2>/dev/null
systemctl is-active --quiet haproxy && systemctl reload haproxy 2>/dev/null
systemctl is-active --quiet postgresql && systemctl reload postgresql 2>/dev/null
systemctl is-active --quiet patroni && systemctl restart patroni 2>/dev/null
systemctl is-active --quiet etcd && { sleep $((RANDOM % 30)); systemctl restart etcd 2>/dev/null; }
$ sudo chmod +x /usr/local/bin/prepare-renewed-ssl-certs.sh
$ echo 'renew_hook = /usr/local/bin/prepare-renewed-ssl-certs.sh' | sudo tee -a /etc/letsencrypt/renewal/node1.example.com.conf
Repeat the renewal hook line for node2 and node3, each pointing at its own renewal conf.
Configure etcd
Patroni uses etcd as its distributed key-value store for leader election.
$ sudo nano /etc/systemd/system/etcd.service
Node1's unit (adjust --name, peer/client URLs, and cert paths per node for node2/node3):
[Unit]
Description=etcd key-value store
Documentation=https://github.com/etcd-io/etcd
After=network.target
[Service]
User=etcd
Type=notify
ExecStart=/usr/local/bin/etcd \
--name node1 \
--data-dir /var/lib/etcd \
--initial-cluster node1=https://node1.example.com:2380,node2=https://node2.example.com:2380,node3=https://node3.example.com:2380 \
--initial-cluster-state new \
--listen-peer-urls https://0.0.0.0:2380 \
--initial-advertise-peer-urls https://node1.example.com:2380 \
--listen-client-urls https://0.0.0.0:2379 \
--advertise-client-urls https://node1.example.com:2379 \
--cert-file /etc/letsencrypt/live/node1.example.com/fullchain.pem \
--key-file /etc/letsencrypt/live/node1.example.com/privkey.pem \
--peer-cert-file /etc/letsencrypt/live/node1.example.com/fullchain.pem \
--peer-key-file /etc/letsencrypt/live/node1.example.com/privkey.pem \
--client-cert-auth=false \
--peer-client-cert-auth=false \
--auto-tls=false \
--peer-auto-tls=false
Restart=always
RestartSec=5
LimitNOFILE=40000
[Install]
WantedBy=multi-user.target
$ sudo mkdir -p /var/lib/etcd /etc/etcd
$ sudo chown -R etcd:etcd /var/lib/etcd /etc/etcd
$ sudo chmod 700 /var/lib/etcd
$ sudo systemctl daemon-reload
$ sudo systemctl enable etcd
$ sudo systemctl start etcd
$ sudo systemctl status etcd
Verify the cluster formed:
$ etcdctl --endpoints=https://node1.example.com:2379 member list
693741ed87a9350d, started, node1, https://node1.example.com:2380, https://node1.example.com:2379, false
d9554ae165b5de0b, started, node3, https://node3.example.com:2380, https://node3.example.com:2379, false
fecdfaa2d1b8f35c, started, node2, https://node2.example.com:2380, https://node2.example.com:2379, false
$ etcdctl --endpoints=https://node1.example.com:2379,https://node2.example.com:2379,https://node3.example.com:2379 endpoint health
Configure PostgreSQL
1. SSL and memory settings (per node, adjust cert paths):
$ sudo nano /etc/postgresql/18/main/postgresql.conf
ssl_cert_file = '/etc/letsencrypt/live/node1.example.com/fullchain.pem'
ssl_key_file = '/etc/letsencrypt/live/node1.example.com/privkey.pem'
shared_buffers = 512MB
work_mem = 8MB
effective_cache_size = 1536MB
2. Enforce SSL connections:
$ sudo nano /etc/postgresql/18/main/pg_hba.conf
hostssl all all 0.0.0.0/0 scram-sha-256
$ sudo systemctl restart postgresql
$ sudo -u postgres psql -c "SHOW ssl;"
Configure Patroni
$ sudo nano /etc/patroni.yaml
Node1's config (adjust name, connect_address, and cert paths for node2/node3):
scope: postgres
name: node1
restapi:
listen: 0.0.0.0:8008
connect_address: node1.example.com:8008
certfile: /etc/letsencrypt/live/node1.example.com/fullchain.pem
keyfile: /etc/letsencrypt/live/node1.example.com/privkey.pem
etcd3:
hosts:
- node1.example.com:2379
- node2.example.com:2379
- node3.example.com:2379
protocol: https
cacert: /etc/ssl/certs/ca-certificates.crt
bootstrap:
dcs:
ttl: 30
loop_wait: 5
retry_timeout: 5
maximum_lag_on_failover: 1048576
initdb:
- encoding: UTF8
- data-checksums
pg_hba:
- host replication replicator 0.0.0.0/0 scram-sha-256
- host all all 0.0.0.0/0 scram-sha-256
postgresql:
listen: 0.0.0.0:5433
connect_address: node1.example.com:5433
data_dir: /var/lib/postgresql/18/main
bin_dir: /usr/lib/postgresql/18/bin
parameters:
ssl: 'on'
ssl_cert_file: '/etc/letsencrypt/live/node1.example.com/fullchain.pem'
ssl_key_file: '/etc/letsencrypt/live/node1.example.com/privkey.pem'
authentication:
replication:
username: replicator
password: StrongPassword123!
superuser:
username: postgres
password: StrongPassword123!
Use identical authentication passwords across all three nodes.
Prepare directories and hand PostgreSQL off to Patroni:
$ sudo mkdir -p /var/run/postgresql
$ sudo chown patroni:patroni /var/run/postgresql
$ sudo chmod 755 /var/run/postgresql
$ echo 'd /var/run/postgresql 0755 patroni patroni -' | sudo tee /etc/tmpfiles.d/postgresql.conf
$ sudo mv /var/lib/postgresql/18 /var/lib/postgresql/18.bak-$(date +%s) || true
$ sudo mkdir -p /var/lib/postgresql/18/main
$ sudo chown -R patroni:patroni /var/lib/postgresql/18
$ sudo chmod -R 700 /var/lib/postgresql/18
$ sudo chmod o+rx /usr/lib/postgresql/18/bin/*
$ sudo systemctl stop postgresql
Create the Patroni systemd unit:
$ sudo nano /etc/systemd/system/patroni.service
[Unit]
Description=Patroni PostgreSQL cluster
After=network.target
[Service]
Type=simple
User=patroni
Group=patroni
ExecStart=/usr/local/bin/patroni /etc/patroni.yaml
ExecReload=/bin/kill -HUP $MAINPID
KillMode=process
TimeoutSec=30
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
$ sudo systemctl daemon-reload
$ sudo systemctl enable patroni
$ sudo systemctl start patroni
Verify cluster state:
$ patronictl -c /etc/patroni.yaml list
+ Cluster: postgres (7561190282296399779) --+-----------+----+-------------+-----+------------+-----+
| Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag |
+--------+------------------------+---------+-----------+----+-------------+-----+------------+-----+
| node1 | node1.example.com:5433 | Leader | running | 1 | | | | |
| node2 | node2.example.com:5433 | Replica | streaming | 1 | 0/4000000 | 0 | 0/4000000 | 0 |
| node3 | node3.example.com:5433 | Replica | streaming | 1 | 0/4000000 | 0 | 0/4000000 | 0 |
+--------+------------------------+---------+-----------+----+-------------+-----+------------+-----+
Configure HAProxy
Same config on all three nodes, load-balancing PostgreSQL connections across the cluster:
$ sudo rm /etc/haproxy/haproxy.cfg
$ sudo nano /etc/haproxy/haproxy.cfg
global
daemon
maxconn 4096
log stdout local0
stats socket /var/lib/haproxy/stats mode 660 level admin
defaults
mode tcp
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
log global
frontend postgres_ssl
bind *:5432
default_backend postgres_backend
backend postgres_backend
balance roundrobin
server node1 node1.example.com:5433 check inter 5000ms rise 2 fall 3
server node2 node2.example.com:5433 check inter 5000ms rise 2 fall 3
server node3 node3.example.com:5433 check inter 5000ms rise 2 fall 3
listen stats
bind *:8404
mode http
stats enable
stats uri /stats
stats refresh 30s
$ sudo systemctl restart haproxy
$ sudo systemctl status haproxy
Test the Cluster
1. Connect through HAProxy:
$ psql 'postgresql://postgres:StrongPassword123!@node1.example.com:5433,node2.example.com:5433,node3.example.com:5433/postgres?sslmode=require&target_session_attrs=read-write'
2. Check the current primary:
postgres=# SELECT inet_server_addr() as ip,
CASE WHEN pg_is_in_recovery() THEN 'Replica' ELSE 'Primary' END as role;
postgres=# \q
3. Trigger a failover by stopping Patroni on the leader:
$ sudo systemctl stop patroni
4. Confirm a new leader was elected from another node:
$ patronictl -c /etc/patroni.yaml list
5. Verify the cluster is still reachable through the same connection string, then bring the stopped node back:
$ sudo systemctl start patroni
Next Steps
The cluster now handles node failure automatically, load-balances client connections through HAProxy, and encrypts all traffic with TLS. From here:
- Add Prometheus + Grafana to track replication lag, connection counts, and failover events
- Tune
maximum_lag_on_failoverandttlinpatroni.yamlfor your latency/consistency tradeoffs - Automate the per-node config differences (name, connect_address, cert paths) with Ansible or similar for easier scaling
For the full guide, visit the original article on Vultr Docs.
Top comments (0)