You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PostgreSQL Patroni HA Cluster with Read/Write Load Balancing
A production-grade Docker Compose stack running a 5-node PostgreSQL 16 cluster with automatic failover, read/write splitting, connection pooling, and full observability. Goal of this repo is to provide a point of reference for bare metal setups
sequenceDiagram
participant C as Client
participant PB as PgBouncer
participant HA as HAProxy
participant PA as Patroni REST API
participant PG as PostgreSQL
Note over HA,PA: Health check loop (every 3s)
HA->>PA: GET /primary (or /replica)
PA-->>HA: 200 {"role":"master"} or 503
C->>PB: SQL query
PB->>HA: Forward (pooled connection)
HA->>PG: Route to healthy backend
PG-->>HA: Result
HA-->>PB: Result
PB-->>C: Result
Loading
Failover
sequenceDiagram
participant PG1 as pg-node-1 (Leader)
participant E as etcd cluster
participant PG2 as pg-node-2 (Replica)
participant HA as HAProxy
PG1-xE: Leader dies / TTL expires
E->>PG2: Lock available
PG2->>E: Acquire leader lock
PG2->>PG2: Promote to primary
HA->>PG2: Health check: GET /primary -> 200
HA->>PG1: Health check: timeout/503
Note over HA: Write traffic rerouted to pg-node-2
PG1->>PG2: Rejoins as replica (pg_rewind)
Loading
Containers
21 containers total across a single patroni-net bridge network.
etcd (3 nodes)
Distributed key-value store for Patroni leader election.
Container
Image
Internal Ports
etcd-1, etcd-2, etcd-3
gcr.io/etcd-development/etcd:v3.5.17
2379 (client), 2380 (peer)
3-node cluster for quorum (tolerates 1 node failure)
Data persisted to named volumes (etcd-{1,2,3}-data)
Exposes /metrics natively on port 2379 (scraped by Prometheus)
Patroni stores leader lock, cluster config, and member registry here
PostgreSQL + Patroni (5 nodes)
PostgreSQL 16 on Alpine, managed by Patroni 3.3.2 for HA.
Container
Image
Internal Ports
pg-node-1 through pg-node-5
postgres:16-alpine + Patroni
5432 (PG), 8008 (Patroni REST)
One node is elected leader (read-write), the other four are streaming replicas (read-only)
Asynchronous replication with max_replication_slots: 10
pg_rewind enabled for fast replica rejoin after failover
password_encryption: md5 for PgBouncer compatibility
max_connections: 200 with superuser_reserved_connections: 5
Data persisted to named volumes (pg-node-{1..5}-data)
Config mounted from ./patroni/patroni.yml (no rebuild needed for config changes)
post_bootstrap.sh runs on first init: creates testdb, pgbouncer user, webuser, and the pgbouncer.get_auth() function
HAProxy
TCP proxy that routes write and read traffic to the correct PostgreSQL nodes.
Container
Image
Exposed Ports
haproxy
haproxy:2.9-alpine
5432 (write), 5433 (read), 7000 (stats)
Write backend (:5432): routes to the Patroni leader only
Read backend (:5433): round-robin across replicas only
Health checks via TCP connection to Patroni REST API (port 8008)
Write: matches "role": "master" in response body
Read: matches "role": "replica" in response body
on-marked-down shutdown-sessions kills stale connections on failover
maxconn 150 per server to stay within PostgreSQL's max_connections
Stats dashboard at http://localhost:7000 (credentials: admin/admin)
PgBouncer (2 instances)
Connection pooler sitting between clients and HAProxy.
docker compose exec pg-node-1 patronictl -c /etc/patroni/patroni.yml list
Test failover
# Kill the current leader
docker compose stop pg-node-2
# Watch a replica promote (check within 30-40 seconds)
docker compose exec pg-node-1 patronictl -c /etc/patroni/patroni.yml list
# Bring it back (rejoins as replica automatically)
docker compose start pg-node-2