Back to all posts

Self-Hosted PostgreSQL HA With pg_auto_failover

Self-hosted PostgreSQL high availability with pg_auto_failover: automatic failover, multi-host connection strings, optional role-aware DNS and WAL-G backups.

David Viejo
David Viejo · Updated · Published

How do you set up PostgreSQL high availability on a self-hosted server?

The short answer: run a pg_auto_failover cluster — a dedicated monitor node plus one or more streaming replicas. When the primary dies, the monitor picks the replica with the freshest WAL and promotes it. Apps connect with a multi-host connection string that finds the new primary on reconnect.

The longer answer is that wiring all of this up by hand (container networking, DNS, WAL archiving, promotion scripts) takes days and breaks in subtle ways. Temps v0.1.0-beta.56 ships it as a built-in feature, labelled Experimental: once worker nodes have joined, choose "Databases → Create Service → PostgreSQL → Topology: Cluster (HA)" and you get a pg_auto_failover cluster managed from the same UI you use for deploys.

TL;DR: Temps v0.1.0-beta.56 makes self-hosted Postgres HA a dashboard operation once worker nodes have joined (HA failover is labelled Experimental). You get pg_auto_failover clusters, automatic and manual failover, a multi-host POSTGRES_URL for linked apps, optional role-aliased DNS (primary.<cluster>.temps.local, experimental and off by default), WAL-G v3.0.8 base backups plus WAL archiving to any S3-compatible bucket, and in-place restore from the latest backup (no point-in-time restore yet). Coolify and Dokploy run a single Postgres container with no HA story. AWS RDS has HA but runs at cloud-provider prices — see the AWS RDS pricing page to compare against Hetzner or DigitalOcean bare-metal rates.


What does PostgreSQL high availability actually require?

Production HA is more than running two Postgres containers. You need:

  1. A health monitor that detects primary failure — not just a load balancer health check, but something that understands Postgres streaming replication state.
  2. Automatic promotion — when the primary is gone, promote the most up-to-date replica and block the old primary from accepting writes (split-brain prevention).
  3. Connection routing that updates — your app's connection string must point to the new primary after failover, without a redeploy.
  4. Continuous WAL archiving — streaming replication alone doesn't protect against accidental DROP TABLE. You need WAL shipped to object storage for point-in-time recovery.

Each of these is solvable. The challenge is integrating all four into something that survives control-plane restarts, node failures, and operator error. Temps v0.1.0-beta.56 covers the first three and ships WAL-G backups with WAL archiving toward the fourth; restoring to a point in time isn't available for clusters yet.


How Temps compares to other self-hosted options

CapabilityTemps (v0.1.0-beta.56)CoolifyDokployAWS RDS Multi-AZ
Single-container PostgresYesYesYesYes
HA cluster (primary + replicas)YesNoNoYes
Automatic failoverYesNoNoYes
One-click manual failoverYesNoNoYes
WAL-G physical backupsYes (WAL-G v3.0.8)NoNoYes (snapshots)
Point-in-time restoreNot yet (latest backup only)NoNoYes
Role-aliased DNS (follows primary)Yes (experimental, off by default)NoNoPartial (endpoint stays fixed)
Self-hostedYesYesYesNo (AWS-only)
Cost (control plane + 1 replica)Server cost onlyServer cost onlyServer cost onlySee AWS pricing page
Managed by a single binaryYesNoNoN/A

Coolify and Dokploy run a Postgres container and stop there. AWS RDS Multi-AZ is mature and battle-tested, but it's cloud-only, priced per instance-hour, and locked to AWS infrastructure. Temps closes the gap for teams running on Hetzner, DigitalOcean, or their own hardware.

One honest caveat: Temps' Postgres HA is new in the v0.1.0 betas, and Temps itself labels HA failover Experimental ("failover semantics are not yet hardened"). AWS RDS Multi-AZ has years of production hardening across millions of databases that a feature this new hasn't accumulated yet — weigh that track record against the cost and lock-in tradeoffs above for anything failover-critical.


pg_auto_failover — the engine under the hood

pg_auto_failover is a PostgreSQL extension that manages a small cluster through a dedicated monitor node. The monitor tracks every data member's health (replication state, LSN position, heartbeat freshness) and triggers state transitions when members deviate from expected states.

The cluster has three roles:

  1. Monitor — a Postgres instance running the pg_auto_failover extension. It's the decision-maker: it holds the FSM state for every node, detects failure, and initiates promotion.
  2. Primary — the active read-write node.
  3. Replica — one or more streaming replicas. Asynchronous by default; the underlying tool supports synchronous too.

When the monitor detects the primary has gone away (configurable timeout, no heartbeat), it selects the replica with the highest LSN and issues a promotion command. The old primary, if it reconnects, re-registers as a replica and starts catching up.

In short, Temps wraps pg_auto_failover, a widely used PostgreSQL failover extension, with a management UI, connection routing and WAL-G physical backups. The HA databases docs are the reference for every step.


DNS routing: <cluster>.temps.local follows the primary

The hardest part of self-hosted HA is connection routing. A naïve setup points your app at a fixed IP — which breaks after failover. Managed services solve this with a stable endpoint that always routes to the primary.

Temps solves it in two layers. Linking the cluster to a project injects a multi-host POSTGRES_URL with target_session_attrs=read-write, so the driver tries each member and settles on whichever one accepts writes. When every member runs on the control plane, the hosts in that URL are plain container names that Docker resolves itself — no extra DNS involved. Once members run on worker nodes, the hosts are per-member *.temps.local names, so turn on Cluster DNS (Worker Nodes → Cluster DNS → Enable cluster DNS). It is experimental and off by default; until you enable it, *.temps.local names don't resolve.

With Cluster DNS on, every cluster gets three tiers of DNS records:

  • Per-member A records — <cluster>-1.<cluster>.temps.local, <cluster>-2.<cluster>.temps.local, etc. One stable name per member; Temps rewrites the address whenever the container restarts.
  • Role-aliased VIPs — primary.<cluster>.temps.local always resolves to the current primary. replica.<cluster>.temps.local resolves to a replica. <cluster>.temps.local round-robins across all data members.
  • Underlay fallback — for single-host setups where no overlay network exists, the record points to the node's underlay address plus host port.

A background reconciler runs every 5 seconds per cluster. It queries the pg_auto_failover monitor for current role assignments and updates the role-aliased VIPs to match. After a promotion, the DNS flip is bounded at roughly 6 seconds (the 5-second tick plus about a second for the node agent to sync).

Either way, apps keep the same connection string across failovers: with the injected multi-host URL, or with primary.<cluster>.temps.local once Cluster DNS is enabled. After a failover, they reconnect on their next query and land on the new primary.


Adding replicas and triggering failover

Adding a replica: click "Add Replica" on a cluster's detail page. This opens /storage/:id/members/add — a dedicated page (not a modal, because provisioning takes a minute and modals make users nervous about navigating away). A 4-step timeline, polled every second, shows provisioning progress:

  1. Reserving slot — allocate the next ordinal
  2. Provisioning container — pull and start the gotempsh/postgres-ha:18-bookworm-walg container on the chosen node
  3. Registering DNS record — publish the member's per-member A record
  4. Joining cluster — pg_autoctl registers the new replica with the monitor

If anything fails mid-way, the UI marks the member as failed and shows the error. Removing a member stops and removes its container, drops it from the monitor, deletes its row and its DNS record.

Manual failover: every replica row in the cluster table has a Promote button. Clicking it runs pg_autoctl perform promotion inside the container. The monitor then demotes the current primary, and the reconciler updates DNS on its next tick. Validation runs server-side: you cannot promote the monitor, the current primary, or a member that isn't running.


WAL-G physical backups with continuous WAL streaming

How do you back up a self-hosted Postgres cluster?

Temps uses WAL-G v3.0.8 baked into gotempsh/postgres-ha:18-bookworm-walg (multi-arch, amd64 + arm64). Clicking the Backup button on a cluster service:

  1. Finds the current primary member.
  2. Writes walg.env (S3 credentials and bucket prefix) to every running data member — so a promoted replica has the credentials it needs to keep archiving.
  3. Runs wal-g backup-push against the primary for a full base backup.
  4. Turns on continuous WAL archiving with ALTER SYSTEM SET archive_command and archive_mode = on.

archive_mode only takes effect when Postgres restarts, so WAL shipping begins after the primary's next restart; the base backup is on S3 straight away. Restores go back to the latest backup — point-in-time recovery for clusters isn't available yet.

Backups go to any S3-compatible bucket. Configure the S3 source once, set it as the default, and reuse it across all services.

For backup scheduling and restores across all managed services, see the backups docs and restore & recovery; for a simpler single-container setup, see how to back up PostgreSQL in Docker automatically.


In-place restore

Restore from the latest cluster backup works as follows:

  1. Stop and remove every member container.
  2. Delete every data volume.
  3. Remove all DNS records and service_members rows.
  4. Pre-seed the new primary's pgdata directory via wal-g backup-fetch in a one-shot helper container. Write recovery.signal and restore_command so Postgres replays WAL to the backup's consistent point on first start.
  5. Re-run initialize_cluster so the rebuilt cluster picks up the recovered data.

The cluster comes back with the same names, ordinals, and FQDNs. Linked apps reconnect with their existing connection string — no connection string updates, no redeploys.

Current limitations: in-place restore is single-host only in v0.1.0-beta.56: it is refused while any member runs on a worker node, so move every member to the control plane first. Multi-host restore requires an agent-side helper-container RPC that didn't make this release. Restore also goes to the latest backup only; there is no point-in-time target yet.


Quorum protection and safe member removal

The UI prevents operations that would silently break the cluster:

  • Cannot remove the monitor — it's a singleton; removing it ends the cluster.
  • Cannot remove the current primary — fail over first, then remove.
  • Cannot remove a replica if it would drop the cluster below 2 data members — HA requires at least primary + 1 replica for any meaningful protection.

Validation runs server-side. The UI hides the Remove and Promote buttons when the action would be rejected, but the backend also enforces these rules independently.


What this replaces and what it doesn't

Temps Postgres HA replaces:

  • Manual pg_auto_failover setup (container networking, certificates, monitor registration scripts)
  • Custom failover notification scripts and DNS update hooks
  • Separate WAL-G configuration per node
  • Homemade restore playbooks

It doesn't replace:

  • Connection pooling — for high-connection workloads, run PgBouncer or pgcat in front. Temps clusters don't bundle a pooler.
  • Major version upgrades — clusters run PostgreSQL 18. There's no in-place upgrade path from older clusters yet.
  • Synchronous replication UI — the underlying pg_auto_failover supports synchronous replication, but the toggle isn't surfaced in v0.1.0-beta.56. Configure via direct pg_autoctl access for now.
  • Multi-region HA — replicas must be reachable from the monitor over the private network you provide between nodes (VPC, VPN or Tailscale). Cross-region setups aren't a first-class workflow yet.
  • Point-in-time recovery — cluster restores go back to the latest WAL-G backup; there is no point-in-time target yet.

Getting started

# Fresh install
curl -fsSL https://temps.sh/deploy.sh | bash

# Existing install: upgrade to a pinned release, then apply database migrations
temps upgrade --version v0.1.0-beta.56
temps migrate

# Join worker nodes first (see /docs/multi-node), then from the dashboard:
# Databases → Create Service → PostgreSQL → Topology: Cluster (HA)
# Then: cluster detail → Add Replica → follow the 4-step timeline

For fresh installs, see the quickstart; for upgrades, upgrading Temps.

Temps is free to self-host (MIT / Apache 2.0 license). Temps Cloud, a hosted control plane for your own servers, is coming soon from $29/month — no per-seat fees, no bandwidth bills.


FAQ

How many replicas do I need for PostgreSQL high availability?

Two data members (primary + 1 replica) is the minimum for any failover protection. With two, the cluster can survive one node failure. Three members (primary + 2 replicas) lets you tolerate one replica being down during a failover without losing quorum. Beyond three, additional replicas mostly serve read traffic — consider whether logical replication read replicas are more cost-effective for that use case.

How long does a Postgres failover take with pg_auto_failover?

Two parts add up. First, the pg_auto_failover monitor has to notice the primary is gone (its health-check timeout, which is configurable) and promote a replica. Then clients have to find the new primary: with the injected multi-host connection string that happens on the next reconnect, and with Cluster DNS enabled, the role-aliased records flip within roughly 6 seconds of the promotion. Queries in flight at the moment of failover return connection errors; apps should retry on reconnect.

Can I use pgvector or TimescaleDB on a Temps HA cluster?

Only extensions present in the image. The gotempsh/postgres-ha:18-bookworm-walg image is postgres:18-bookworm plus pg_auto_failover and WAL-G, so contrib extensions available in the base image work with CREATE EXTENSION, while pgvector and TimescaleDB are not bundled in it. Both are compatible with pg_auto_failover in principle; for now, run them on a single-node managed Postgres with the pgvector or timescaledb image (managed PostgreSQL docs).

Where do WAL-G backups go?

Any S3-compatible bucket: AWS S3, Cloudflare R2, Hetzner Object Storage, a self-hosted RustFS, etc. Configure credentials once and mark that S3 source as the default (Backups → S3 sources → Set as default). WAL-G handles upload, compression and backup metadata.

Is self-hosted Postgres HA safe for production?

The underlying components — pg_auto_failover and WAL-G — are production-grade tools used at scale by teams outside of Temps. Temps's wrapper (provisioning, DNS reconciliation, UI) has been tested through scale-up, scale-down, failover, backup, and restore cycles, including reconciler survival across control-plane restarts. For any new infrastructure component, run it on a non-critical workload first and validate your restore procedure before relying on it for critical data.

Related: Read the full v0.1.0 release notes

#postgresql high availability#pg_auto_failover#wal-g#postgres backups#self-hosted postgres#database failover