patroni-leader-proxy¶
Modules
A NixOS module: a local HAProxy that gives PostgreSQL clients a fixed local endpoint that always lands on the current Patroni leader. Failover needs no client reconfiguration, no DNS change, no restart.
The problem¶
You run PostgreSQL in HA with Patroni. Patroni elects a leader and, on failure, promotes a replica — but the leader is now a different host. Every write client has to find the new one. Chasing that with DNS TTLs, floating VIPs, or client-side multi-host connection strings is either slow, fiddly, or unsupported by your driver.
The insight¶
Patroni already exposes a REST API that answers, per node, "am I the leader?":
GET /primary→ 200 only on the current leader, 503 otherwiseGET /replica→ 200 only on a running replica
So point HAProxy in TCP mode at the raw PostgreSQL port of every node, but use
the REST API as the health check. The read-write pool then holds exactly one
"healthy" server — the leader — and HAProxy re-points to a newly promoted leader
within inter × fall seconds. Clients only ever talk to 127.0.0.1:<port>; the
proxy quietly follows the leader around the cluster.
An optional read-only pool health-checks /replica and round-robins across live
replicas.
client ──▶ 127.0.0.1:5432 (HAProxy, this host)
│ TCP-forwards PG port
│ health-checks Patroni REST /primary
├─▶ pg1:5432 (/primary → 200 ← leader, gets traffic)
├─▶ pg2:5432 (/primary → 503 ← replica, no traffic)
└─▶ pg3:5432 (/primary → 503 ← replica, no traffic)
Usage¶
{
imports = [ ./modules/patroni-leader-proxy ];
services.patroni-leader-proxy = {
enable = true;
nodes = {
pg1 = "10.0.0.11";
pg2 = "10.0.0.12";
pg3 = "10.0.0.13";
};
# readPort = 5433; # optional round-robin replica pool
};
}
Clients connect to 127.0.0.1:5432 for writes (always the leader) and, if you
set readPort, 127.0.0.1:5433 for reads (any live replica).
Key options¶
| Option | Default | Purpose |
|---|---|---|
nodes |
(required) | name -> address of every Patroni member. |
pgPort |
5432 |
PostgreSQL port on each node (forwarded to). |
restApiPort |
8008 |
Patroni REST API port (health-checked). |
port |
5432 |
Local read-write listen port. |
readPort |
null |
Local read-only (replica round-robin) port; off by default. |
bindAddresses |
[ "127.0.0.1" ] |
Where HAProxy listens (see below). |
extraAfterUnits |
[ ] |
Extra after= units, e.g. a VPN (see below). |
checkInter / checkTimeout / checkFall / checkRise |
5s / 8s / 5 / 2 |
Health-check tuning (see below). |
Traps and tunings (do not naively "tighten")¶
Servers start DOWN, on purpose¶
HAProxy's default is to consider a health-checked server UP until a check
proves otherwise. For a pool whose entire job is "only the leader", that default
is backwards: for up to checkInter × checkFall after HAProxy starts — 25
seconds at the defaults here — every node is in the RW pool, and a write can be
round-robined onto a replica.
default-server init-state down inverts it: a node joins the pool only after
checkRise successful checks say it is the primary. The cost is that the RW
port refuses connections for up to one checkInter after a proxy restart. That
is the right trade: a brief, obvious outage beats a silent write to a replica.
This requires HAProxy 3.1 or newer. On older builds HAProxy rejects the unknown keyword and refuses to start, which is at least a loud failure rather than a quiet misroute.
WAN-tolerant timings. If the proxy health-checks nodes across a high-latency
link (a stretched cross-region cluster), a healthy /primary check can take
1–2s over a ~150ms RTT. A tight inter 3s / timeout check 3s flaps the whole
pool DOWN on every jitter spike — dropping all writes for no reason. The defaults
here are inter 5s fall 5 rise 2 (≈25s to mark a node down) with
timeout check 8s. Raise them further for slower links; do not lower them
because the LAN case "looks fine".
Live connections survive blips. HAProxy's on-marked-down shutdown-sessions
is deliberately not set. On a transient health-check failure you want
existing PostgreSQL connections to survive, not be killed and re-pooled. (The
leader itself hasn't moved — only a check timed out.)
Boot ordering when checks ride a VPN. If the Patroni nodes are reachable only
over an overlay network (Tailscale, WireGuard, ...), HAProxy starting before that
interface is up trips every server to "No route to host" and leaves the RW pool
empty for ~30s until checks recover. Pass the overlay's unit via
extraAfterUnits = [ "tailscaled.service" ]; (or your WireGuard unit).
network-online.target is always ordered before HAProxy.
Log-noise suppression. option dontlog-normal drops the clean-termination
line every PostgreSQL connection emits. On a busy host this is tens of thousands
of lines per boot that otherwise drown the DOWN/UP/retry events you actually care
about.
Binding for containers / VMs¶
bindAddresses defaults to loopback, for host-local consumers. To let containers
or microvms on the same host reach the proxy, add the bridge / VM gateway IP:
If that bridge IP may not exist yet when HAProxy starts, allow non-local binds:
Notes¶
- This is the write-path companion to your Patroni setup — Patroni elects the leader; this module finds it. It does not manage PostgreSQL or Patroni itself.
- Running the proxy on the same host as a Patroni member is fine — just set
portto something other than 5432 (e.g.15432) so it doesn't collide with the local PostgreSQL. - A Patroni member tagged
nofailoverstill answers/replicawhile it streams, so it will appear in areadPortpool. Exclude it fromnodesif you don't want reads routed there.
Source¶
modules/patroni-leader-proxy/default.nix
# patroni-leader-proxy
#
# A local HAProxy that gives PostgreSQL clients a fixed endpoint which always
# lands on the CURRENT Patroni leader. HAProxy TCP-forwards the PG port but
# health-checks Patroni's REST API: `GET /primary` returns 200 only on the
# leader and `GET /replica` returns 200 only on running replicas. The RW pool
# therefore holds exactly one live server and re-points to a new leader within
# `(inter x fall)` seconds of a failover — no client reconfig, DNS, or restart.
#
# Import it, set `nodes` to your Patroni members, enable it, and point clients
# at 127.0.0.1:<port>.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.patroni-leader-proxy;
serverLines = lib.concatStringsSep "\n " (
lib.mapAttrsToList (
name: addr: "server ${name} ${addr}:${toString cfg.pgPort} check port ${toString cfg.restApiPort}"
) cfg.nodes
);
bindLines =
port: lib.concatStringsSep "\n " (map (addr: "bind ${addr}:${toString port}") cfg.bindAddresses);
rwBlock = ''
listen patroni-rw
${bindLines cfg.port}
option httpchk
http-check send meth GET uri /primary
http-check expect status 200
timeout check ${cfg.checkTimeout}
default-server init-state down inter ${cfg.checkInter} fall ${toString cfg.checkFall} rise ${toString cfg.checkRise}
${serverLines}
'';
roBlock = lib.optionalString (cfg.readPort != null) ''
listen patroni-ro
${bindLines cfg.readPort}
balance roundrobin
option httpchk
http-check send meth GET uri /replica
http-check expect status 200
timeout check ${cfg.checkTimeout}
default-server init-state down inter ${cfg.checkInter} fall ${toString cfg.checkFall} rise ${toString cfg.checkRise}
${serverLines}
'';
in
{
options.services.patroni-leader-proxy = {
enable = lib.mkEnableOption "Local HAProxy that routes PostgreSQL to the current Patroni leader";
nodes = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
example = {
pg1 = "10.0.0.11";
pg2 = "10.0.0.12";
pg3 = "10.0.0.13";
};
description = ''
The Patroni members, as an attrset of `name -> address`. `name` is the
HAProxy server label (shown in logs / stats); `address` is the host or
IP where that node's PostgreSQL and Patroni REST API listen. Every node
appears in both the RW and RO pools — Patroni's REST API decides which
one is live for each role, so you never edit this on failover.
'';
};
pgPort = lib.mkOption {
type = lib.types.port;
default = 5432;
description = "Port each Patroni node's PostgreSQL listens on (forwarded to by HAProxy).";
};
restApiPort = lib.mkOption {
type = lib.types.port;
default = 8008;
description = "Port each Patroni node's REST API listens on (used for the role health check).";
};
port = lib.mkOption {
type = lib.types.port;
default = 5432;
description = ''
Local TCP port for read-write (leader) connections. Set this to
something other than 5432 (e.g. 15432) if this host itself runs a
PostgreSQL/Patroni that already owns 5432.
'';
};
readPort = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
example = 5433;
description = "If set, a local TCP port that round-robins across running replicas.";
};
bindAddresses = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "127.0.0.1" ];
description = ''
Addresses HAProxy binds the read-write (and read) pools on. Defaults to
loopback for host-local consumers. Add bridge / VM gateway IPs (e.g.
"172.20.0.1" or "192.168.121.1") so containers and microvms on this host
can reach the proxy. Binding a not-yet-existing bridge IP requires
`boot.kernel.sysctl."net.ipv4.ip_nonlocal_bind" = 1;`.
'';
};
checkInter = lib.mkOption {
type = lib.types.str;
default = "5s";
description = ''
HAProxy `inter` — how often the Patroni REST health check runs.
Combined with `checkFall`, a node is marked down after
`checkInter x checkFall`. Keep this generous over high-latency /
cross-region links: a too-tight `inter` flaps the pool down on jitter.
'';
};
checkTimeout = lib.mkOption {
type = lib.types.str;
default = "8s";
description = ''
HAProxy `timeout check`. Must comfortably exceed the worst-case latency
of a `/primary` REST response over your slowest link (a healthy check
across a ~150ms RTT WAN can take 1-2s).
'';
};
checkFall = lib.mkOption {
type = lib.types.int;
default = 5;
description = "Consecutive failed checks before a node is marked down (see `checkInter`).";
};
checkRise = lib.mkOption {
type = lib.types.int;
default = 2;
description = "Consecutive successful checks before a node is marked up again.";
};
extraAfterUnits = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "tailscaled.service" ];
description = ''
Extra systemd units to order the HAProxy service `after`. If the health
checks reach the Patroni nodes over a VPN / overlay network (Tailscale,
WireGuard, ...), add that unit here: starting before the overlay is up
trips every server to "No route to host" and leaves the RW pool empty
for ~30s until checks recover. `network-online.target` is always included.
'';
};
};
config = lib.mkIf cfg.enable {
services.haproxy = {
enable = true;
config = ''
global
maxconn 2000
log /dev/log local0
defaults
mode tcp
log global
option tcplog
option dontlog-normal
timeout connect 10s
timeout client 1h
timeout server 1h
${rwBlock}${roBlock}
'';
};
systemd.services.haproxy = {
after = [ "network-online.target" ] ++ cfg.extraAfterUnits;
wants = [ "network-online.target" ];
};
};
}