The problem
PostgreSQL can replicate and can be promoted, but nothing in it decides which node should be primary after the current one stops answering, and nothing tells clients where the primary now is.
What makes it hard
- A node cannot distinguish "the primary is dead" from "I cannot currently see the primary", so any decision made locally can produce two primaries.
- Detecting failure faster and tolerating network noise pull in opposite directions, and both are governed by the same timing budget.
- Synchronous replication converts a durability risk into an availability risk; asynchronous replication does the reverse. There is no setting that avoids both.
- Clients hold long-lived pooled connections, so a correct promotion still leaves sessions attached to a node that is no longer primary.
- The consensus store is now in the write path for availability decisions, so its disk latency becomes the database's availability budget.
Components
How it fails
| Failure | How you notice | What bounds it |
|---|---|---|
| The consensus store becomes unreachable and Patroni demotes a healthy primary to read-only. | Writes start failing with read-only transaction errors while every postgres process is up; etcd leader-change counters and fsync-duration histograms spike at the same moment. | Hold loop_wait + 2 x retry_timeout <= ttl with real slack, put etcd on dedicated low-latency disks, and enable Patroni failsafe_mode so a primary that can still reach every member keeps running. |
| Split brain - the old primary keeps accepting commits after another node has been promoted. | Two nodes report Leader, or the rejoining node refuses to stream and reports a diverged timeline. Application-side symptom is rows that exist on one node only. | A watchdog device with mode required, so a node that cannot run its HA loop is reset rather than left writing, plus pre_promote fencing for anything outside postgres. |
| HAProxy health check points at the wrong endpoint and writes are routed to a replica. | Errors of the form "cannot execute INSERT in a read-only transaction" from a subset of connections, immediately after a failover rather than during it. | Check /primary for the write backend and /replica for the read backend, and assert in a post-failover test that the write backend has exactly one server up. The /leader endpoint checks the leader lock, not whether PostgreSQL is a writable primary. |
| The virtual IP does not move, or moves to a host where HAProxy is not running. | Clients time out at connect while both HAProxy instances are healthy; VRRP state transitions absent from the keepalived logs on both nodes. | A track_script that checks the local HAProxy rather than only the interface, with weight arithmetic that actually inverts the priority ordering. |
| Failover succeeds but pooled client connections stay attached to the demoted node. | Application error rate stays elevated long after patronictl list shows a healthy new leader. | on-marked-down shutdown-sessions on the HAProxy write backend, and a pool that validates connections rather than trusting them. |
Obligations that attach
When not to use this
- A single-node deployment where the recovery objective is met by restoring a backup. This topology adds four components and does not reduce the chance of data loss from a bad migration, which is the more common incident.
- A managed PostgreSQL service already provides the failover and the endpoint. Running Patroni beside it duplicates the decision maker.
- Networks that do not pass VRRP multicast or do not permit an unallocated address to be claimed by a host - most cloud virtual networks. Use a cloud load balancer for the address layer and keep the rest.
- Multi-region clusters where the round trip exceeds the commit budget. The timing arithmetic here assumes a single low-latency segment; stretching it produces elections caused by latency rather than failure.
t=0
Primary host stops. No demotion, no shutdown, no notice.
In-flight writes hang.
Health check fails
HAProxy's check against the leader endpoint fails. The write backend now has zero servers up.
Connect attempts to the write port are refused rather than hung.
Lease expires
The leader key in etcd expires because nobody renewed it.
Still no writable node.
Replicas race
Replicas notice the absent leader key, compare WAL positions, and race for the key.
Nothing yet.
Winner promotes
The winner activates its watchdog, runs pre_promote if configured, and promotes.
Nothing yet - HAProxy has not re-checked.
HAProxy re-checks
The new leader's endpoint returns 200. HAProxy brings it up in the write backend.
Writes succeed again.
Old host rejoins
Patroni rewinds it with pg_rewind if it diverged, or rebuilds it, then it rejoins as a replica.
Read capacity returns.
The useful way to read this topology is as four separate questions, each answered by a different component, with no component answering two of them. Confusion about which layer owns which question is the source of most of the operational pain. It is worth being pedantic about it up front.
| Question | Answered by | Not answered by |
|---|---|---|
| Which node can hold the leader lock? | etcd, which arbitrates competing Patroni requests through the distributed configuration store | PostgreSQL, which does not participate in this election |
| Who is primary, and who makes it so? | Patroni | PostgreSQL, which promotes only when told |
| Where do I send this connection? | HAProxy, by asking Patroni’s REST API | etcd, which clients never talk to |
| What address do clients name? | keepalived, by moving a virtual IP | HAProxy, which has no address of its own |
PostgreSQL sits underneath all four. It streams WAL, it accepts a promotion request, and it has no view whatsoever on cluster membership. Every piece of decision making above it is bolted on, which is why the assembly has this many moving parts.
Normal operation
Separate the traffic paths
The diagram separates application connections, replication and coordination. Repeated boxes name the same roles, not extra machines. The virtual IP (VIP) belongs to the active proxy host, rather than to the database primary. Write-ahead log (WAL) records travel between database servers, not through the application proxy. The control paths decide where a new connection should go; they do not carry its queries.
| Path | Direction and owner | What this does not establish |
|---|---|---|
| Application | Client connects to the VIP, HAProxy opens a connection to the selected primary | Existing sessions do not migrate when a role or address changes |
| Replication | A replica connects to its upstream PostgreSQL; that upstream sends WAL records back | A received record is not necessarily replayed or acknowledged durably |
| Role checks | HAProxy queries Patroni on candidate database hosts and uses the responses to select a backend | Process liveness alone does not establish the writable role |
| Coordination | Patroni instances read cluster state and compete for or renew the leader lock in etcd | etcd does not execute promotion inside PostgreSQL |
| Local control | Patroni manages the PostgreSQL process on its own host | Patroni is not a query proxy |
| Address ownership | Keepalived on proxy hosts manages the VIP using the Virtual Router Redundancy Protocol | Moving the proxy address does not select or promote a database primary |
This view follows the Patroni role-check contract, Patroni architecture, PostgreSQL streaming replication and Keepalived’s address failover model. It intentionally omits backup storage, monitoring and network security controls; a deployable design still needs them. The diagram introduces no port defaults or outage-time promises.
Diagnostic exercise: imagine the application cannot connect while replication continues. Trace which lines can still be healthy and identify the first observation you would collect at the client, proxy and Patroni endpoint. Then repeat with successful connections but growing replication lag. A useful answer distinguishes address reachability, backend eligibility and WAL progress instead of treating them as one database health signal. Compare your explanation with the failure timeline below; this is a tabletop exercise, not an instruction to disrupt a production cluster.
Follow the steady-state loop
Nothing dramatic happens in steady state, and it is worth walking the loop anyway because every failure is a deviation from it.
Each Patroni instance wakes on its own cycle, reads the cluster state from etcd, looks at the local postmaster, and acts. The instance holding the leader key renews the lease; the others see a leader key that is not theirs, confirm they are streaming from the address it advertises, and go back to sleep. Each write to etcd goes through Raft, so a renewal only succeeds if a majority of the etcd members have durably accepted it. That is the whole safety property: a node runs as primary only while it can prove, to a majority, that it still holds the lease.
Meanwhile HAProxy is polling each node’s Patroni REST API on a separate port from the database
traffic. Use /primary for the write backend: it requires PostgreSQL to be primary and Patroni
to hold the leader lock. /leader checks the lock but also permits a standby-cluster leader,
so it is not an equivalent write check. /replica requires a running replica without the
noloadbalance tag. This is the mechanism that lets a TCP proxy route by role despite
the PostgreSQL wire protocol being completely opaque to it. HAProxy proxies bytes; the decision comes
from an HTTP question asked elsewhere.
keepalived, running on the two proxy hosts, does the least interesting and most easily forgotten thing: one of them holds the address in DNS, and it advertises over VRRP so that the other knows to stay quiet.
The failure timeline
The sequence below is what an unclean loss of the primary host looks like. The order matters more than the exact durations, which are functions of your own timing configuration.
| Stage | What happens | What clients see |
|---|---|---|
| t=0 | Primary host stops. No demotion, no shutdown, no notice. | In-flight writes hang. |
| within one health-check window | HAProxy’s check against the leader endpoint fails. The write backend now has zero servers up. | Connect attempts to the write port are refused rather than hung. This is an improvement, and it is worth arranging deliberately. |
within ttl |
The leader key in etcd expires because nobody renewed it. | Still no writable node. |
| next loop iteration on each replica | Replicas notice the absent leader key, compare their WAL positions, and race for the key. Only a candidate within maximum_lag_on_failover may take part; only a synchronous candidate may, if synchronous mode is on. |
Nothing yet. |
| immediately after | The winner activates its watchdog, runs pre_promote if configured, and promotes. Timeline increments. |
Nothing yet - HAProxy has not re-checked. |
| next check | The new leader’s endpoint returns 200. HAProxy brings it up in the write backend. | Writes succeed again. |
| when the old host returns | Patroni rewinds it with pg_rewind if it diverged, or rebuilds it, then it rejoins as a replica. |
Read capacity returns. |
Two consequences fall out of that table. First, the outage is roughly the lease expiry plus a loop iteration plus a health-check interval. The proxy’s polling interval is additive, and tuning Patroni’s timers while leaving a lazy health check in place buys nothing. Second, the failure detection is not the failover: HAProxy withdrew the write backend long before any node was promoted, so there is a window where clients get clean refusals rather than promotion. Applications that retry on connection failure ride through it. Applications that treat a connection error as fatal do not, and no amount of cluster tuning fixes that.
The clean case - a planned switchover - inverts the order. Patroni demotes the current leader first, waits for the candidate to catch up, then promotes, so no committed transaction is at risk. That is why the switchover and failover commands are distinct operations and not aliases, and why draining the write backend in HAProxy before a maintenance switchover turns a brief error burst into nothing at all.
The configuration decisions that actually matter
Size etcd for consensus, not for the database. Three or five members, on their own hosts, with their own disks. An etcd member acknowledges a Raft write only after an fsync, so its disk latency becomes the latency of every lease renewal, and a store that is slow to renew produces demotions of perfectly healthy primaries. It looks like a PostgreSQL problem and is not. This is the single most common way this topology fails. Co-locating etcd with the databases, or with Kubernetes’ own etcd, is how you get a correlated failure: the thing that is supposed to survive the database host dies with it.
Spend the timing budget deliberately. Patroni’s timers are bound by a documented inequality relating the loop interval, the retry timeout and the lease TTL. It is a budget, not a suggestion: the leader must be able to lose one attempt against etcd, retry, sleep a full loop, and still renew before the lease expires. Shortening the TTL shortens the write outage and simultaneously reduces your tolerance for a paused VM, a garbage-collection stall or an etcd leader election. There is no setting that is aggressive about detection and forgiving about noise.
Choose the durability contract before you need it. Asynchronous replication can lose committed transactions on promotion; that is not a bug, it is the definition. Patroni’s synchronous mode constrains the election to nodes known to hold every acknowledged commit, and its strict variant refuses to fall back when no synchronous candidate exists, which means writes block instead. Say plainly which failure you prefer. A ledger takes the outage; a session store does not. Run at least three data nodes if you choose synchronous, so losing one host does not cost write availability.
Decide pg_rewind at bootstrap. Rewinding a diverged former primary requires data checksums or
WAL hints, and turning checksums on later means rewriting the cluster. Initialise with
--data-checksums. The related settings that delete a data directory when rewind fails default to
off, which is the conservative choice and also how you end up with a node that reports as a replica
for days and never reaches streaming. Check for that after every failover.
Treat the watchdog as part of the safety argument, not a nicety. Patroni’s promise to demote when it loses the lease only holds while Patroni is being scheduled. A hardware or software watchdog armed before promotion and disarmed on demotion converts “process starved, still writing” into “host reset”. Without it, the consensus store’s correctness does not protect you, because the dangerous node is not participating in consensus at all. See fencing and split brain.
Make HAProxy’s role checks explicit. Two backends, two endpoints, mode tcp set explicitly
and not inherited. Set the timeouts: HAProxy has no useful defaults for long-lived database
sessions, and an unset client or server timeout produces connections that hang instead of failing.
Add on-marked-down shutdown-sessions to the write backend so that a demoted node’s existing
sessions are torn down, not left to discover their read-only status one failed INSERT at a
time.
Do not put keepalived on the database nodes. It is tempting, because it removes a tier. It is also how you build a system in which a VRRP election, which knows nothing about WAL positions, effectively selects a primary. keepalived moves an address and runs a script; it has no concept of database health and cannot prevent two nodes from believing they are in charge. Keep it on the proxy tier, where the only thing it decides is which HAProxy answers, and give it a track script that checks HAProxy, not merely that the interface is up.
The alternative that removes a tier
Modern PostgreSQL client libraries accept a multi-host connection string with a session-attribute preference, so the driver can try each host and keep the one that is writable. That removes both the proxy and the virtual IP, and with them two failure domains, one of which - the address layer - does not work on most cloud networks anyway.
The cost is that routing policy now lives in every application’s connection string. Changing the node list means a deploy of every service. There is no central place to drain a node before maintenance, no observability on connection distribution, and no shared connection pooling. It is a good trade for a small number of first-party services and a poor one for a fleet of applications you do not all control. Where connection count, not routing, is the binding constraint, PgBouncer in front of the proxy addresses a different problem and is complementary rather than an alternative.
What to watch
Four signals, one per layer, are enough to tell you where an incident lives.
| Signal | Layer | What a bad reading means |
|---|---|---|
| etcd fsync duration and leader changes | Consensus | Demotions and elections are being caused by storage, not by failure |
| Leader-key changes per hour | Decision | The cluster is flapping; your timing budget is too tight for the network you have |
| Replay lag in bytes per replica | Data | Failover would lose data, or would be refused by the lag threshold |
| Servers up in the write backend | Routing | Zero means no writable node; two means split brain, and it is the most urgent alert in the system |
That last row is worth an alert with a page attached. Everything else in this topology degrades; two servers up in a write backend is an active correctness failure, and the cluster will not resolve it for you.