HAProxy
HAProxy is the load balancer the Patroni docs
recommend putting in front of a cluster: clients connect to one stable address,
HAProxy health-checks each member’s REST API, and routes writes to the current
leader (and, in read/write-split mode, reads to the replicas). pgcli runs it as
a standalone, top-level addon — shared routing infrastructure, not a
per-instance sidecar — pinned to the official haproxy:3.2.23-alpine image.
Platform support: the HAProxy addon is Linux-only for now. It reaches Patroni members over the host network, which the macOS
podman machinedoes not expose to containers. On macOSNewHAProxyManagerfails fast with a clear message;pg addon liststill shows installed instances without live status.
How It Works
Routing follows the official Patroni haproxy.cfg
pattern. Each backend is a Patroni member; two ports matter per member:
- the PostgreSQL port — the TCP port HAProxy actually forwards connections to;
- the REST API port — where HAProxy sends its
httpchkhealth check.
Patroni’s REST API answers these GETs (unauthenticated by design):
| Endpoint | Returns 200 on |
|---|---|
GET / |
the leader only |
GET /replica (optionally ?lag=<max>) |
replicas only, and only if their lag is within the limit |
So the leader is the sole UP server for a write listener, and the replicas are
the sole UP servers for a read listener — failover is picked up automatically
as the health checks flip. pgcli renders this into
<base-dir>/addon/haproxy/<name>/haproxy.cfg and bind-mounts it read-only into
the container.
Two modes
The mode is chosen with --mode:
unified(default) — a single listener sends all traffic to the current leader. Simple; every connection can read and write. On leader change the write listener useson-marked-down shutdown-sessionsto drop stale connections so clients reconnect to the new leader.split— read/write separation: a_<name>_rwlistener routes writes to the leader, and a separate_<name>_rolistener spreads reads across the replicas (round-robin, lag-filtered). Two client ports instead of one.
Stats page
Every instance also gets an HTTP stats listener so you can watch server
up/down state in a browser: http://<listen>:<stats-port>/.
Install
Backend members are supplied one of two ways (mutually exclusive):
--node NAME=HOST:PGPORT:RESTPORT(repeatable) — list them explicitly;--ha <scope>— auto-derive every local member of a Patroni scope thatpg hamanages, using each member’s PostgreSQL and REST API ports.
What each --node field means — for node1=10.0.0.11:35532:8008:
| Field | Meaning |
|---|---|
node1 |
member name — becomes the server name in haproxy.cfg and the label in the stats page |
10.0.0.11 |
address where this member is reachable from this host (the member’s advertise-host, or 127.0.0.1 on the same host) |
35532 |
the member’s PostgreSQL port that HAProxy forwards connections to |
8008 |
the member’s Patroni REST API port, used for the health check |
With --ha, all four come from pg.yaml: the Patroni member name, its
advertise-host (default 127.0.0.1), and its host_port / restapi_port.
The output reports the assigned ports and a ready-to-use connect URL:
Adding or removing members later
The backend list follows the scope’s local members recorded in pg.yaml, so
any topology change — or removal — is picked up by re-running the same
install command: it re-derives the full backend list and recreates the
container.
Added with pg ha create <scope> --member <m>, the new member is not in the
running HAProxy config yet:
Symmetrically, after pg ha remove <scope> --member <m> the removed member
would otherwise linger in the config as a stale backend — re-running install
drops it:
For an explicit --node install there is no auto-derivation: edit the --node
list itself (add or drop a spec) and re-run — the target list is replaced
wholesale each time, so the command always reflects the full set you want.
Cross-host clusters
--ha derives backends from this host’s pg.yaml, which lists only the
members this host’s pg ha create manages. A member living on another host was
never a backend here — adding or removing one there requires no re-sync on
this host (and leaves nothing stale). To front the whole cluster through one
HAProxy, use an explicit --node list, with each member’s advertised host:
Caveat: an instance installed with --ha on one host routes writes only to a
local leader — if failover promotes a remote member, the rw listener has no
UP server until the leader moves back. For cross-host clusters where the leader
can move, prefer the explicit all-members --node list (or run one HAProxy per
host and front it with a higher-level VIP/DNS).
Read/Write Split in Practice
After installing with --mode split, two client ports are exposed:
| Port | Traffic | Backend |
|---|---|---|
write_port (default 5000) |
Read + write | Leader only |
read_port (default 5001) |
Read only | Replicas (round-robin, lag ≤ max-lag) |
stats_port (default 5002) |
HTTP stats page | — |
After a failover, HAProxy picks up the new leader automatically via health
checks — no connection string change needed. A browser at
http://127.0.0.1:5002/ shows real-time backend state.
Ports
Ports are auto-assigned from a per-host pool, three consecutive free ports per
instance (rw, then ro if split, then stats), starting at haproxy_start_port
(default 5000). Override any of them explicitly:
Because the pool scans for free ports and tracks what every HAProxy instance
has already taken, you can run many instances on one machine without
collisions. A split instance consumes 3 ports (rw / ro / stats), a unified
instance 2 (rw / stats):
The bind address defaults to 127.0.0.1; set --listen 0.0.0.0 — or the
listen key in pg.yaml — to expose the listeners on the network. Keep it on
loopback unless the backends require otherwise; there is no authentication in
front of PostgreSQL here.
Configuration
Everything lives under the top-level addons.haproxy map in pg.yaml, keyed by
instance name:
targets is rendered from and re-derived by install; edit mode, ports,
replica_max_lag, or image_tag here for fine control, then
pg addon install haproxy --name lb ... to apply. The rendered haproxy.cfg
carries no secrets — only hosts, ports, and health-check URIs.
List
Start and stop
After a host reboot, bring an instance back without re-rendering config:
install always recreates the container so config changes take effect;
start only starts an existing one (and self-heals an improper state by
recreating from the haproxy.cfg on disk).
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring an instance up after a host reboot:
This sets autostart: true and installs/refreshes the boot service (see
Auto-start on Boot). Boot is start-only: it starts the
existing container reading the haproxy.cfg already on disk, and never
re-renders config — install the instance first. The boot service starts
HAProxy after the Patroni members, so its backends are already coming up.
pg autostart status lists every target’s state.
Remove
Stops and removes the container and deletes its config directory.
Logs
The container logs to stdout (log stdout format raw local0 info), so health-check
transitions and connection events show up here.
Troubleshooting
- All backends DOWN in the stats page. The health check hits each member’s
REST API port. Confirm it is reachable —
curl http://<host>:<rest_port>/returns 200 on the leader, 503 elsewhere;/replicais the mirror image. A000means the REST API is down or the port is wrong. --hafinds no members.--haonly derives local members thatpg hamanages inpg.yaml. For remote members, or a scope pgcli doesn’t manage, list them with--node.- Port collision on manual
--rw-port. Pick a port outside the auto pool (haproxy_start_portand up) or the auto-assigner will treat it as taken. - macOS. Not supported yet — HAProxy needs host networking to reach Patroni members, which the podman machine VM does not provide to containers.