An Eight Mile project · open source
PostgreSQL primary routing.
An Ansible role that deploys Spilo/PostgreSQL members, an etcd quorum and a global HAProxy service on Docker Swarm. HAProxy probes Patroni on port 8008 and routes traffic from haproxy:5000 only to the member returning 200 for /primary.
Frontend
haproxy:5000
Health
Patroni /primary
Runtime
HAProxy 3.0
Published ports
none
Primary route
live topology
Primary selection
Patroni’s REST state controls the HAProxy backend.
HAProxy performs an HTTP check against port 8008 on every PostgreSQL member. The /primary endpoint returns 200 only on the current Patroni leader, so the TCP frontend on port 5000 has exactly one writable backend in the UP state.
Schematic — the primary shown is illustrative; Patroni can promote any eligible member.
3s
Patroni health-check interval
fall 3
Failed checks before marked down
rise 2
Healthy checks before restored
30m
Client and server TCP timeout
Component architecture
Control plane, database members and TCP routing.
etcd stores the distributed leader state, Patroni manages PostgreSQL promotion and replication, HAProxy converts Patroni state into backend availability, and Swarm places each stateful service on the host containing its bind mounts.
HAProxy
TCP frontend with Patroni-aware backend selection.
·
TCP mode on :5000
·
HTTP health checks on :8008
·
Global Swarm service
Spilo + Patroni
PostgreSQL 17 managed by Patroni inside Spilo.
·
Leader election and promotion
·
Replication slots + pg_rewind
·
Asynchronous replication
etcd
Distributed configuration store used for leader election.
·
One member per declared node
·
Leader key and cluster state
·
Majority quorum required
Docker Swarm
Service placement and private overlay networking.
·
DNSRR service discovery
·
Host-pinned stateful members
·
Immutable Swarm configs
Configuration reference
HAProxy, Patroni and Swarm configuration.
These excerpts define the primary health check, backend transition thresholds, global service placement, Patroni failover parameters and checksum-versioned Swarm config.
ansible-role-postgres
cfg · jinja2
TCP frontend with HTTP health checks against Patroni /primary.
listen postgres bind *:{{ postgres_haproxy_port }} option httpchk GET /primary http-check expect status 200 default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions {% for node in postgres_replica_members %} server postgres{{ loop.index }} postgres{{ loop.index }}:{{ postgres_port }} check port 8008 {% endfor %}
yaml · jinja2
Global HAProxy service mounting a checksum-versioned Swarm config.
haproxy: image: {{ haproxy_image }} networks: - {{ postgres_network }} configs: - source: haproxy_cfg target: /usr/local/etc/haproxy/haproxy.cfg healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:{{ postgres_haproxy_stats_port }}/ >/dev/null || exit 1"] interval: 5s timeout: 3s retries: 3 deploy: mode: global
yaml · jinja2
Patroni DCS timing, failover lag and replication parameters.
bootstrap: dcs: ttl: 30 loop_wait: 10 retry_timeout: 10 maximum_lag_on_failover: 1048576 # Async replication: a failover can lose the last few transactions, but # writes never block on a standby being reachable. synchronous_mode: false postgresql: use_pg_rewind: true use_slots: true
ansible · yaml
Rendered HAProxy config and checksum calculation on the manager.
- name: Render HAProxy config file template: src: haproxy.cfg.j2 dest: '{{ postgres_stack_dir }}/haproxy.cfg' owner: root group: root mode: '0644' when: swarm_role == 'manager' # Swarm configs are immutable, so the config name is suffixed with the file's # checksum: edit haproxy.cfg and the name changes, which forces the global # haproxy service to pick up the new version on redeploy. - name: Checksum the HAProxy config (for the swarm config name) stat: path: '{{ postgres_stack_dir }}/haproxy.cfg' register: _haproxy_cfg when: swarm_role == 'manager'
Failure handling
Quorum loss removes write authority.
Primary failure triggers Patroni promotion and HAProxy backend convergence. If etcd loses majority quorum, Patroni demotes the database to read-only rather than allowing multiple members to accept writes.
Event
Cluster response
Current PostgreSQL primary fails
Patroni promotes an eligible standby; HAProxy follows its /primary response.
One etcd member is lost
The surviving quorum keeps serving. The lost member must be re-added manually.
Two of three etcd members are lost
Quorum is gone; Patroni demotes the database to read-only until etcd returns.
Network exposure
PostgreSQL, etcd, the SQL frontend and stats page publish no host ports. Applications join db-network and use haproxy:5000.
Primary-routed logical backups
A manager-only pg_dumpall runs through HAProxy, captures databases and roles, compresses the dump, and prunes local copies after seven days by default.
etcd member recovery
A lost etcd member cannot rejoin with initial state set to new. Remove it, add it back, then start only that member with state existing.
on-marked-down shutdown-sessions drops sessions to the failed primary instead of letting them linger.
Deployment and inspection
Required inputs and runtime state.
Pre-deployment assertions reject missing or placeholder database credentials. After deployment, patronictl reports member roles and replication state, while the overlay-only HAProxy stats endpoint reports backend health.
Required deployment inputs
postgres_replica_members
Hostnames and per-member memory budgets.
postgres_password
Required superuser secret, supplied through Vault.
postgres_standby_password
Required replication secret in HA mode.
db-network
External overlay created before this role deploys.
Inspect the live route
$
docker exec $(docker ps -qf name=postgres_postgres1) patronictl list
$
docker run --rm --network db-network curlimages/curl -s http://haproxy:7000/
The stats page remains overlay-only. Tunnel it for browser access instead of publishing port 7000.
Behind the project
Built by Eight Mile in London, as part of our infrastructure and cloud work — the same engineers who build and run systems like it for clients.