Operations guide Hetzner · netcup · STRATO · as of 2 Nov 2026

nextxch highly available across multiple providers

When a whole provider goes offline, mail should keep flowing: without expensive anycast, without enterprise load balancers, deployable from Git. This guide shows three setups on low-cost servers at Hetzner, netcup and STRATO, explains second by second what happens during an outage, and calculates resources and cost. Every provider claim links to a source with the date checked, every nextxch claim names the source file in the code.

01 · What nextxch provides for high availability (checked in the code)

nextxch is active-active capable: every node accepts SMTP, IMAP, JMAP, ActiveSync and HTTPS, there is no passive node and no sticky session. PostgreSQL holds the shared state, the messages themselves are stored as SHA-256-addressed .eml objects in the blob store.

CapabilityHowSource in code
Cluster backplane without RedisPostgreSQL LISTEN/NOTIFY (channel nextxch_cluster_events) for events such as new mail, flags, expunge, queue; UNLOGGED tables cluster_ephemeral and cluster_nodes; heartbeat every 15 s, nodes dropped after 60 s without heartbeatsrc/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs
Leader taskspg_try_advisory_lock: exactly one node renews ACME certificates, sends the licence heartbeat, syncs LDAP, sends calendar remindersAcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs
SMTP queue across nodesFOR UPDATE SKIP LOCKED: each node claims different queue items, no double deliverysrc/Nextxch.Storage/Postgres/PostgresMailStore.cs
Cluster-capable databasesPostgreSQL (default for clusters) and SQL Server (sp_getapplock); SQLite single node onlysrc/Nextxch.Storage/StorageServiceCollectionExtensions.cs
Blob storeFileSystem, S3 (own SigV4 client with Content-MD5) or Hybrid (local NVMe cache + S3). Object Lock modes Normal, Governance, Compliance and legal holdsrc/Nextxch.Storage/Blobs/S3BlobStore.cs, HybridBlobStore.cs
Certificates & DKIMACME certificates and DKIM keys live in the database; HTTP-01 challenges are shared across the cluster so any node can answer; the leaf key is kept on renewal (DANE 3 1 1 stays valid)src/Nextxch.Security/Tls/AcmeCertificateManager.cs
Health endpoints/health/live (no DB), /health/ready (DB reachable, otherwise 503), /health (overview incl. cluster peers)src/Nextxch.Server/Program.cs
Real client IPPROXY protocol v1/v2 for SMTP, IMAP, ManageSieve (Cluster:EnableProxyProtocol) and X-Forwarded-For for HTTP from TrustedProxyCidrssrc/Nextxch.Core/Abstractions/IClusterCoordinator.cs
Deploy artefactsdocker-compose.yml, Kubernetes manifests (3 replicas, HPA 2 to 10, PDB, probes), Helm chart, deb/rpm, Windows MSIdeploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/
LicenceActive mailboxes are counted, not nodes or pods; licence state is shared through cluster_kvsrc/Nextxch.Server/Services/ClusterLicenseService.cs, Pricing

Ports per node: 25, 465, 587 (SMTP), 143, 993 (IMAP), 4190 (ManageSieve), 80/443 (HTTPS, ACME, webmail, ActiveSync, JMAP). POP3 110/995 is off by default (Pop3:Enabled=false in Pop3Settings.cs).

The one hard requirement
All nodes need exactly one writable PostgreSQL primary and one shared blob store at any time. Without a reachable database, /health/ready returns 503 and the node cannot verify recipients. High availability across providers therefore means replicating PostgreSQL and the blob store across sites and switching automatically. That is what the three setups build.

02 · Mail-specific rules that make high availability easy

Inbound: MX is built-in failover

Senders sort MX records by preference and must try every address; with equal preference they must randomize. [1] If a target is unreachable, the retry interval should be at least 30 minutes and the give-up time at least 4 to 5 days. [1]

Outbound: SPF and PTR per IP

SPF must list every outbound IP of every site; ip4/ip6 do not count towards the limit of 10 DNS lookups. [2] Gmail requires valid forward and reverse DNS; the PTR is set at each provider separately. [8][14][27][31]

DKIM, MTA-STS, DANE

DKIM keys live in the cluster database, one DNS set serves all nodes; selectors allow several keys in parallel. [3] The MTA-STS policy must name every MX host; wildcards only cover the left-most label. [4] DANE needs one TLSA record per MX host, 3 1 1 is recommended, and a DNSSEC-signed zone. [5]

Clients: IMAP, submission, HTTPS

Mail clients have no MX-style failover. They connect to one name (here mail.example.com) whose A records are maintained by a health-check job. IDLE connections are re-issued at least every 29 minutes and then land on a healthy node. [7][6]

DNS zone for two sites (template)
; Zone example.com (TTL in seconds). IPs are documentation ranges: replace them.
; Site A = Hetzner 192.0.2.10, site B = netcup 198.51.100.20, site C (medium setup) = 203.0.113.30

; Inbound SMTP: equal preference, senders MUST randomize (RFC 5321 section 5.1).
example.com.            3600 IN MX 10 mx1.example.com.
example.com.            3600 IN MX 10 mx2.example.com.

; Static per-site names: never touched by the failover job (stable FCrDNS for PTR).
mx1.example.com.        3600 IN A     192.0.2.10
mx2.example.com.        3600 IN A     198.51.100.20

; Client name (IMAP, submission, HTTPS, webmail, ActiveSync, autodiscover):
; short TTL, kept up to date by the health-check job. Start with all healthy nodes.
mail.example.com.         60 IN A     192.0.2.10
mail.example.com.         60 IN A     198.51.100.20

; SPF: every outbound IP of every site. ip4/ip6 do not count against the 10-lookup limit (RFC 7208 4.6.4).
example.com.            3600 IN TXT   "v=spf1 ip4:192.0.2.10 ip4:198.51.100.20 ip4:203.0.113.30 -all"

; DKIM: nextxch keeps the keys in the cluster database, so one record set serves all nodes.
; Publish the selectors shown under /admin/ (Domains > DKIM).

; MTA-STS: the policy must list every MX host (see the note on gaps).
_mta-sts.example.com.   3600 IN TXT   "v=STSv1; id=20261102"

; DANE (optional, needs a DNSSEC-signed zone): one TLSA record per MX host.
; nextxch reuses the leaf key on renewal, so "3 1 1" stays stable.
_25._tcp.mx1.example.com. 3600 IN TLSA 3 1 1 <sha256-of-spki>
_25._tcp.mx2.example.com. 3600 IN TLSA 3 1 1 <sha256-of-spki>

; PTR (set in the provider console, not in this zone):
;   192.0.2.10    -> mx1.example.com   (Hetzner Console / Robot)
;   198.51.100.20 -> mx2.example.com   (netcup SCP)
EHLO name and PTR
The server host name (mail.example.com, for clients, autodiscover and the certificate) applies cluster-wide. Each node sets its own EHLO/HELO name: Smtp:HeloHostname in the config or in /admin/ under "Hostname, IPv6 & Ports" (EHLO name per node, with a PTR check per node). It is used for the 220 banner, the EHLO reply and outbound EHLO. Point the PTR of each IP at its fixed name mx1/mx2; the DNS advisor shows a HELO SPF record per node. RFC 5321 section 4.1.4 forbids refusing a message only because of a mismatching EHLO name anyway. [1][8]
EHLO name per node
# appsettings.Production.json per node (or admin: Hostname, IPv6 & Ports > EHLO name per node)
# Site A (Hetzner):
{ "Smtp": { "ServerHostName": "mail.example.com", "HeloHostname": "mx1.example.com" } }
# Site B (netcup):
{ "Smtp": { "ServerHostName": "mail.example.com", "HeloHostname": "mx2.example.com" } }
# PTR 192.0.2.10 -> mx1.example.com, PTR 198.51.100.20 -> mx2.example.com
MTA-STS policy with all MX hosts (served by nextxch per domain)
# Served by nextxch itself at https://mta-sts.example.com/.well-known/mta-sts.txt
# (admin: Domains > domain > Transport > MTA-STS). Empty MX list = automatic:
# the domain's MX records from DNS + server host name + EHLO names of all nodes.
# Wildcards like *.example.com are allowed. Every change changes the policy id;
# the DNS advisor shows the matching _mta-sts TXT record.
version: STSv1
mode: enforce
mx: mx1.example.com
mx: mx2.example.com
max_age: 604800

03 · How clients are redirected: options compared

Provider failover IPs only help inside one provider. For the outage of a whole provider, DNS remains the simple and cheap tool.

OptionScopeCostAssessment
Several MX with equal preferenceacross providers0 €Mandatory for inbound mail, standardised [1]
Own health-check job + DNS API (Cloudflare, Hetzner, deSEC, netcup)across providers0 €Recommended. Cloudflare: TTL from 60 s (Enterprise 30 s), auto = 300 s. Hetzner DNS: free, TTL from 60 s, API in the Hetzner Console. deSEC: per-domain minimum TTL, example 3600 s, lower only on request. netcup: CCP DNS API exists, minimum TTL not documented. [38][39][21][40][29]
Cloudflare Load Balancing (DNS-only)across providersfrom USD 5/monthHealth checks (incl. TCP, SMTP) every 15 s outside Enterprise; for A/AAAA/CNAME, MX/SRV Enterprise only. Replaces the own job for mail.example.com. [33][34][35][36]
Cloudflare Spectrumacross providersEnterpriseArbitrary TCP ports (25, 465, 587, 993) only as Enterprise add-on; Pro/Business only SSH, Minecraft, RDP. [37]
Hetzner Floating IP (Cloud)one project, one network zoneIPv4 €3.00, IPv6 €1.00/monthOnly inside Hetzner [9]
Hetzner failover IP (Robot, dedicated)Hetzner data centres€4.20/month + €4.90 setupSwitch via Robot API in 90 to 110 s, only inside Hetzner [11]
netcup failover IPsame netcup location€5.00/month incl. VATRe-route in SCP any time for free, only inside netcup [24][25]
Akamai/Linode IP sharingone data centre, two Linodesfrom USD 5 (Nanode)BGP-based (lelastic), same data centre only; NodeBalancer Basic USD 10, TCP/HTTP [41][42]
Anycast / own BGPglobalhighOwn ASN (RIPE contract via membership or sponsoring LIR) plus own address space (at Vultr at least /24 IPv4 or /48 IPv6). Out of proportion for most nextxch installations. [43][44]
Why a witness is needed
With two sites neither can tell whether the other one failed or only the link broke. If both promoted themselves, two diverging databases would result (split brain). etcd decides by majority: two of three members are enough. Hence: three votes at three providers, where the third may be a small witness. [48][53]

04 · Setup S: two sites plus witness

For small and medium companies. Site A (e.g. Hetzner Cloud) and site B (e.g. netcup VPS) each run nextxch, PostgreSQL under Patroni, one etcd member and one Garage zone. A small witness C at a third provider (e.g. STRATO) holds the third etcd member, the third Garage copy and the DNS failover checker. All three are meshed with WireGuard.

flowchart TB
  U["Users: IMAP, SMTP submission, HTTPS"] -->|"mail.example.com TTL 60"| DNS["DNS with health-check job"]
  S["Remote mail servers"] -->|"MX 10 mx1 / MX 10 mx2"| A1
  S --> B1
  DNS --> A1
  DNS --> B1
  subgraph A["Site A: Hetzner"]
    A1["nextxch"] --> AP[("PostgreSQL primary")]
    A1 --> AG["Garage zone A"]
    AE["etcd"]
  end
  subgraph B["Site B: netcup"]
    B1["nextxch"] --> BP[("PostgreSQL replica")]
    B1 --> BG["Garage zone B"]
    BE["etcd"]
  end
  subgraph C["Witness C: STRATO"]
    CE["etcd"]
    CG["Garage zone C"]
    CF["Failover checker"]
  end
  B1 -.->|"WireGuard"| AP
  AP ==>|"synchronous replication"| BP
  AG <-.-> BG
  BG <-.-> CG
  AE <-.-> BE
  BE <-.-> CE
Architecture setup S

Why Garage as blob store?

An S3 bucket at a single provider would be gone with that provider, and nextxch can currently write to only one S3 target. Garage is an S3-compatible store (AGPL-3.0) built for several data centres over the internet (project requirement: at most 200 ms latency, at least 50 Mbit/s). With replication_factor = 3 there is one copy per zone; reads and writes continue as long as only one zone fails (write quorum 2). With only two zones, losing one zone would leave the store read-only, which is why the witness also holds a full copy. [60][61]

Not tested and without Object Lock
Garage supports neither S3 Object Lock nor versioning. Domains with WORM retention (governance/compliance) make nextxch send retention headers and therefore need an S3 with Object Lock; see chapter 09 for the GoBD copy. Running nextxch on Garage was not load-tested for this guide: verify with test data before production. [62]

Step by step

  1. Order servers: A = Hetzner Cloud (ports 25/465 are blocked by default; request unblocking after one month and a paid first invoice, 587 is open), B = netcup VPS (remove the firewall policy "netcup Mail block" in the SCP), C = STRATO VPS as witness (the witness does not need port 25). [13][26]
  2. Set PTR: IP A to mx1.example.com (Hetzner Console), IP B to mx2.example.com (netcup SCP). [14][27]
  3. Base install via cloud-init (Debian 13): deploy user, Docker, WireGuard, no password login.
  4. WireGuard mesh 10.88.0.0/24 between A, B, C; open UDP 51820 only for the peer IPs. [47]
  5. etcd on all three (election timeout at least 10 × RTT), then Patroni + PostgreSQL 18 on A and B. [50][53]
  6. Garage on all three, apply a layout with three zones, create bucket and key. [61]
  7. nextxch via Docker Compose on A and B (Cluster:Enabled=true, multi-host connection string, S3 pointing to local Garage). In /admin/ set the host name mail.example.com and issue the ACME certificate for mail, mx1, mx2.
  8. DNS as in chapter 02, check the MTA-STS MX list in /admin/, enable DNS failover in /admin/ under Domains (only the cluster leader acts) or alternatively the script under an etcd lock.
  9. Backups as in chapter 09 and test a restore. Then rehearse an outage: hard-stop site A and follow chapter 08.
cloud-init (user data)
#cloud-config
# Hetzner Cloud / netcup: paste as user data when creating the VM.
users:
  - name: deploy
    groups: [sudo, docker]
    shell: /bin/bash
    sudo: "ALL=(ALL) NOPASSWD:ALL"
    ssh_authorized_keys:
      - ssh-ed25519 AAAA... ci-deploy
package_update: true
packages: [wireguard, curl, jq, docker.io, docker-compose-v2, unattended-upgrades]
runcmd:
  - [ systemctl, enable, --now, docker ]
  - [ sed, -i, "s/^#\\?PasswordAuthentication.*/PasswordAuthentication no/", /etc/ssh/sshd_config ]
  - [ systemctl, restart, ssh ]
WireGuard: /etc/wireguard/wg0.conf
# /etc/wireguard/wg0.conf on site A (Hetzner). Same pattern on B and C.
# Full mesh: every site has one [Peer] block per other site.
[Interface]
Address    = 10.88.0.1/24
ListenPort = 51820
PrivateKey = <private-key-of-site-A>   # wg genkey, never commit it

[Peer]  # site B (netcup)
PublicKey           = <public-key-of-site-B>
Endpoint            = 198.51.100.20:51820
AllowedIPs          = 10.88.0.2/32
PersistentKeepalive = 25

[Peer]  # site C (witness or third site)
PublicKey           = <public-key-of-site-C>
Endpoint            = 203.0.113.30:51820
AllowedIPs          = 10.88.0.3/32
PersistentKeepalive = 25

# systemctl enable --now wg-quick@wg0
# Firewall: UDP 51820 open only for the two peer IPs.
etcd: /etc/default/etcd
# /etc/default/etcd on site A (Debian package etcd-server). Sites B and C analogue.
# Three members = quorum of two: any single site may fail.
ETCD_NAME="site-a"
ETCD_DATA_DIR="/var/lib/etcd/default"
ETCD_LISTEN_PEER_URLS="http://10.88.0.1:2380"
ETCD_LISTEN_CLIENT_URLS="http://10.88.0.1:2379,http://127.0.0.1:2379"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://10.88.0.1:2380"
ETCD_ADVERTISE_CLIENT_URLS="http://10.88.0.1:2379"
ETCD_INITIAL_CLUSTER="site-a=http://10.88.0.1:2380,site-b=http://10.88.0.2:2380,site-c=http://10.88.0.3:2380"
ETCD_INITIAL_CLUSTER_STATE="new"
ETCD_INITIAL_CLUSTER_TOKEN="nextxch-ha"
# WAN tuning (etcd tuning guide: election timeout at least 10x the round trip).
# Example for up to ~25 ms RTT between providers:
ETCD_HEARTBEAT_INTERVAL="250"
ETCD_ELECTION_TIMEOUT="2500"
# Traffic stays inside WireGuard. For production add TLS (ETCD_CERT_FILE etc.).
Patroni: /etc/patroni/config.yml
# /etc/patroni/config.yml on site A (Debian/PGDG package "patroni").
# Site B: name/addresses analogue. The witness C runs etcd only, no PostgreSQL.
scope: nextxch
name: pg-site-a

restapi:
  listen: 10.88.0.1:8008
  connect_address: 10.88.0.1:8008

etcd3:
  hosts: 10.88.0.1:2379,10.88.0.2:2379,10.88.0.3:2379

bootstrap:
  dcs:
    ttl: 30                 # defaults; rule: loop_wait + 2 * retry_timeout <= ttl
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576
    synchronous_mode: on    # a 250 OK to a sender is only given after the commit reached a 2nd site
    synchronous_mode_strict: false   # if the replica site is gone, keep accepting mail (falls back to async)
    failsafe_mode: true
    postgresql:
      use_pg_rewind: true
      parameters:
        wal_level: replica
        hot_standby: "on"
        max_wal_senders: 10
        max_replication_slots: 10
        wal_log_hints: "on"
        archive_mode: "on"
        archive_command: "pgbackrest --stanza=nextxch archive-push %p"
  initdb: [ {encoding: UTF8}, data-checksums ]

postgresql:
  listen: 10.88.0.1:5432
  connect_address: 10.88.0.1:5432
  data_dir: /var/lib/postgresql/18/main
  bin_dir: /usr/lib/postgresql/18/bin
  authentication:
    replication: { username: replicator, password: "${PATRONI_REPLICATION_PASSWORD}" }
    superuser:   { username: postgres,   password: "${PATRONI_SUPERUSER_PASSWORD}" }
  pg_hba:
    - host replication replicator 10.88.0.0/24 scram-sha-256
    - host nextxch     nextxch    10.88.0.0/24 scram-sha-256
Garage: /etc/garage.toml
# /etc/garage.toml, identical on all three sites except rpc_public_addr.
metadata_dir = "/var/lib/garage/meta"
data_dir     = "/var/lib/garage/data"
db_engine    = "lmdb"

replication_factor = 3          # one copy per zone (site); writes need 2 of 3
consistency_mode   = "consistent"

rpc_bind_addr   = "10.88.0.1:3901"
rpc_public_addr = "10.88.0.1:3901"
rpc_secret      = "<openssl rand -hex 32, same on all nodes>"

[s3_api]
s3_region     = "garage"
api_bind_addr = "127.0.0.1:3900"   # each nextxch node talks to its local Garage

# After the first start (node ids from `garage node id`):
#   garage layout assign <id-a> -z hetzner -c 500G
#   garage layout assign <id-b> -z netcup  -c 500G
#   garage layout assign <id-c> -z witness -c 500G
#   garage layout show && garage layout apply --version 1
#   garage bucket create nextxch
#   garage key create nextxch-app
#   garage bucket allow --read --write --owner nextxch --key nextxch-app
nextxch: docker-compose.yml per site
# docker-compose.yml on site A and site B (nextxch only; PostgreSQL/Patroni, etcd,
# Garage and WireGuard run as system services on the same VM).
# Secrets come from .env (never committed): NEXTXCH_LICENSE, PG_PASSWORD, S3_KEY, S3_SECRET
name: nextxch

services:
  nextxch:
    image: nextxch/server:${NEXTXCH_TAG}
    restart: unless-stopped
    network_mode: host            # real client IPs for SPF/DMARC, direct WireGuard reach
    environment:
      ASPNETCORE_URLS: "http://*:80;https://*:443"
      License__Token: "${NEXTXCH_LICENSE}"
      Cluster__Enabled: "true"
      Cluster__NodeId: "${SITE_NAME}"          # hetzner-a / netcup-b
      Storage__Provider: "Postgres"
      # Npgsql multi-host: always connects to the current Patroni primary
      Storage__PostgresConnectionString: "Host=10.88.0.1,10.88.0.2;Port=5432;Database=nextxch;Username=nextxch;Password=${PG_PASSWORD};Target Session Attributes=primary;Host Recheck Seconds=5"
      Storage__BlobBackend: "S3"
      Storage__Compression: "Zstd"
      Storage__S3__EndpointUrl: "http://127.0.0.1:3900"
      Storage__S3__Region: "garage"
      Storage__S3__BucketName: "nextxch"
      Storage__S3__ForcePathStyle: "true"
      Storage__S3__AccessKey: "${S3_KEY}"
      Storage__S3__SecretKey: "${S3_SECRET}"
      Smtp__ServerHostName: "mail.example.com"
      Imap__ServerHostName: "mail.example.com"
      ManageSieve__ServerHostName: "mail.example.com"
      Acme__Enabled: "true"
      Acme__ContactEmail: "postmaster@example.com"
    volumes:
      - nextxch-data:/app/data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1/health/ready"]
      interval: 15s
      timeout: 5s
      retries: 3

volumes:
  nextxch-data:

The connection string names both PostgreSQL hosts with Target Session Attributes=primary. nextxch creates its connections through NpgsqlDataSource.Create (PostgresMailStore.cs); with several hosts Npgsql automatically builds a multi-host data source and connects only to the current primary, re-checking the state every Host Recheck Seconds (default 10 s). After a Patroni failover the nodes reconnect without any config change; the cluster LISTEN connection retries every 2 s (PostgresClusterCoordinator.cs). [63]

Automatic DNS failover for mail.example.com

nextxch ships a DNS failover agent (opt-in, off by default; /admin/ under Domains, card "DNS failover"). Every node checks the SMTP banner on port 25 and HTTPS /health of every node through its public IP every 30 s. Only the cluster leader acts: it holds a PostgreSQL advisory lock, combines the view of all nodes by majority and writes the A/AAAA records of the configured names through the Hetzner DNS (Cloud API) or Cloudflare API. Hysteresis: after N failed rounds a node leaves DNS, after M healthy rounds it returns; the last node is never removed. Every change is in the event log, the alert rule "DNS failover: node taken out" notifies via the ops alerts. The API token is encrypted with the server keyring and never shown again.

Built-in DNS failover agent
# Admin: Domains > DNS failover (cluster). Stored cluster-wide; the token is encrypted
# with the server keyring and never shown again. Equivalent settings:
Provider:          Hetzner DNS (Cloud API, api.hetzner.cloud)   | Cloudflare DNS
Zone:              example.com
Names:             mail.example.com            # A/AAAA follow the healthy nodes
TTL:               60                          # resolvers keep old answers this long
Interval:          30 s
Failures (N):      3                           # 3 x 30 s before a node leaves DNS
Successes (M):     5                           # 5 x 30 s before it returns
Checks:            SMTP 25 (220 banner), HTTPS /health
Nodes:             fsn1, 192.0.2.10, 2001:db8::10
                   nbg1, 198.51.100.20
# Alert: rule "DNS failover: node taken out" (Alerts) fires when a node is out of DNS.
Why a cut-off node does not change DNS
The leader lock only exists through a connection to the PostgreSQL primary. With Target Session Attributes=primary in the connection string (chapter 04 above) a cut-off side reaches no primary, does not get the lock and stays passive. The majority vote over all observers stops a single node with a broken view from removing a healthy node.

Alternative: script under an etcd lock

For DNS providers without a built-in connector (e.g. deSEC), or when etcd should make the decision, the script remains an equivalent alternative. Do not run agent and script for the same names at the same time. The script checks /health/ready of every node through its public IP every 20 s and reconciles the A records through the Cloudflare API (PATCH/POST/DELETE on dns_records). It runs on every site under etcdctl lock: only one copy acts, and only on the side with etcd majority. A cut-off site therefore cannot change DNS any more. For Hetzner DNS or deSEC only the three curl calls change. [39]

nextxch-dns-failover.sh
#!/usr/bin/env bash
# /usr/local/bin/nextxch-dns-failover.sh
# ALTERNATIVE to the built-in agent (admin: Domains > DNS failover), e.g. for DNS
# providers without a built-in connector (deSEC) or when etcd decides anyway.
# Keeps the A records of mail.example.com equal to the set of healthy nodes.
# Runs on every site under an etcd lock, so only one copy acts, and only on the
# side that still has etcd quorum (a cut-off minority site can never change DNS).
#   ExecStart=etcdctl --endpoints=http://127.0.0.1:2379 lock nextxch-dns /usr/local/bin/nextxch-dns-failover.sh
# Needs: curl, jq. Secrets via systemd EnvironmentFile (CF_API_TOKEN, CF_ZONE_ID).
set -euo pipefail

NAME="mail.example.com"
TTL=60
NODES=("192.0.2.10" "198.51.100.20")     # public IPs of the nextxch nodes
FAILS_BEFORE_REMOVE=3                    # 3 x 20 s = 1 minute
STATE=/run/nextxch-failover; mkdir -p "$STATE"
API="https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records"
AUTH=(-H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json")

healthy() {   # HTTPS readiness through the public IP, with SNI for the real name
  curl -fsS --max-time 5 --resolve "${NAME}:443:$1" "https://${NAME}/health/ready" \
    | jq -e '.status == "ready"' >/dev/null
}

while true; do
  want=()
  for ip in "${NODES[@]}"; do
    f="$STATE/$ip"; n=$(cat "$f" 2>/dev/null || echo 0)
    if healthy "$ip"; then echo 0 > "$f"; want+=("$ip")
    else n=$((n+1)); echo "$n" > "$f"; [ "$n" -lt "$FAILS_BEFORE_REMOVE" ] && want+=("$ip"); fi
  done
  # Never publish an empty set: if everything looks down, the checker is probably the problem.
  [ "${#want[@]}" -eq 0 ] && { sleep 20; continue; }

  have=$(curl -fsS "${AUTH[@]}" "${API}?type=A&name=${NAME}" | jq -r '.result[] | "\(.id) \(.content)"')
  for ip in "${want[@]}"; do   # add missing healthy IPs
    grep -q " ${ip}$" <<<"$have" || curl -fsS "${AUTH[@]}" -X POST "$API" \
      --data "{\"type\":\"A\",\"name\":\"${NAME}\",\"content\":\"${ip}\",\"ttl\":${TTL},\"proxied\":false}" >/dev/null
  done
  while read -r id ip; do      # remove IPs that are no longer healthy
    [ -z "${id:-}" ] && continue
    printf '%s\n' "${want[@]}" | grep -qx "$ip" || curl -fsS "${AUTH[@]}" -X DELETE "${API}/${id}" >/dev/null
  done <<<"$have"
  sleep 20
done
systemd unit
# /etc/systemd/system/nextxch-dns-failover.service (install on every site)
[Unit]
Description=nextxch DNS failover (runs only while holding the etcd lock)
After=network-online.target etcd.service wg-quick@wg0.service
Wants=network-online.target

[Service]
EnvironmentFile=/etc/nextxch/dns-failover.env
ExecStart=/usr/bin/etcdctl --endpoints=http://127.0.0.1:2379 lock nextxch-dns /usr/local/bin/nextxch-dns-failover.sh
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

05 · Setup M: three full sites (Proxmox or plain VMs)

Like setup S, but all three sites run nextxch with a PostgreSQL replica. When one site fails, two databases and two nextxch nodes remain, and with synchronous_mode even synchronous replication is kept. Three MX records with equal preference spread inbound load.

flowchart TB
  S["Remote mail servers"] -->|"MX 10 mx1, mx2, mx3"| N
  U["Users"] -->|"mail.example.com"| N
  subgraph N["WireGuard mesh 10.88.0.0/24"]
    direction LR
    subgraph A["Hetzner"]
      A1["nextxch"] --- A2[("Patroni + PG")] --- A3["etcd + Garage"]
    end
    subgraph B["netcup"]
      B1["nextxch"] --- B2[("Patroni + PG")] --- B3["etcd + Garage"]
    end
    subgraph C["Third provider"]
      C1["nextxch"] --- C2[("Patroni + PG")] --- C3["etcd + Garage"]
    end
  end
  N --> BK["Backups: Storage Box + S3 at another provider"]
Architecture setup M
Proxmox: no cluster across providers
According to Proxmox, corosync needs latencies below 5 ms (LAN level); beyond about 10 ms and more than three nodes it is unlikely to work. A single Proxmox VE cluster spanning Hetzner, netcup and STRATO is therefore not recommended. The right pattern: one independent Proxmox host or cluster per provider, with high availability provided by the application (Patroni, Garage, nextxch cluster). A QDevice (corosync-qnetd) is meant for two-node clusters at one site and is not bound by the latency limit. The Proxmox Datacenter Manager (1.0 since 2025-12-04) manages several clusters together. [45]

Step by step

  1. Three VMs (or Proxmox guests) at three providers, same Debian version, PTR to mx1/mx2/mx3 each.
  2. Ansible inventory with three hosts, one playbook for all (WireGuard, etcd, Patroni, Garage, nextxch).
  3. Patroni config from setup S with a third host; synchronous_mode: on, with three databases optionally quorum. [54]
  4. Connection string with three hosts, Garage layout with three zones, three MX records, three IPs in SPF and in the DNS failover node list.
  5. Proxmox Backup Server optional for VM images: datastore local or S3 (S3 backend officially supported since PBS 4.2); Proxmox does not document network shares such as a Storage Box via CIFS as a datastore type. [46]
Ansible: inventory/ha.yml
# inventory/ha.yml: three sites, one VM (or Proxmox guest) per site
all:
  vars:
    ansible_user: deploy
    wg_network: 10.88.0.0/24
    pg_major: 18
  children:
    nextxch_sites:
      hosts:
        hetzner-a: { ansible_host: 192.0.2.10,    wg_ip: 10.88.0.1, garage_zone: hetzner }
        netcup-b:  { ansible_host: 198.51.100.20, wg_ip: 10.88.0.2, garage_zone: netcup }
        third-c:   { ansible_host: 203.0.113.30,  wg_ip: 10.88.0.3, garage_zone: third }
Ansible: site.yml
# site.yml: idempotent base for every site (same playbook for Hetzner, netcup, Strato ...)
- hosts: nextxch_sites
  become: true
  tasks:
    - name: Base packages (Debian 13)
      ansible.builtin.apt:
        name: [wireguard, etcd-server, etcd-client, patroni, pgbackrest, docker.io, docker-compose-v2, jq, curl]
        update_cache: true
    # PostgreSQL 18 itself comes from the PGDG apt repository (apt.postgresql.org),
    # Garage as the static binary or the dxflrs/garage image.

    - name: WireGuard config
      ansible.builtin.template: { src: wg0.conf.j2, dest: /etc/wireguard/wg0.conf, mode: "0600" }
      notify: restart wg

    - name: etcd, Patroni and Garage config
      ansible.builtin.template: { src: "{{ item.src }}", dest: "{{ item.dest }}", mode: "0640" }
      loop:
        - { src: etcd.env.j2,     dest: /etc/default/etcd }
        - { src: patroni.yml.j2,  dest: /etc/patroni/config.yml }
        - { src: garage.toml.j2,  dest: /etc/garage.toml }

    - name: nextxch compose file
      ansible.builtin.template: { src: docker-compose.yml.j2, dest: /opt/nextxch/docker-compose.yml }

    - name: Start nextxch
      ansible.builtin.command: docker compose up -d
      args: { chdir: /opt/nextxch }
      changed_when: false

  handlers:
    - name: restart wg
      ansible.builtin.service: { name: wg-quick@wg0, state: restarted }

06 · Setup L: Kubernetes at three providers

For many mailboxes and horizontal scaling. Important first: k3s explicitly does not support embedded etcd in a cluster spread across clouds; server nodes should stay in one location. Hence: one Kubernetes cluster per provider (k3s or Talos each), each running a nextxch deployment from the shipped Helm chart. The data layer (Patroni + etcd + Garage) runs on its own VMs over WireGuard as in setup M and is used by all three clusters. [49][48]

flowchart TB
  DNS["DNS: MX per provider LB, mail.example.com with health checks"]
  subgraph K1["k3s/Talos cluster Hetzner"]
    L1["Load balancer"] --> P1["nextxch pods (HPA)"]
  end
  subgraph K2["k3s/Talos cluster netcup"]
    L2["Load balancer"] --> P2["nextxch pods (HPA)"]
  end
  subgraph K3["k3s/Talos cluster STRATO"]
    L3["Load balancer"] --> P3["nextxch pods (HPA)"]
  end
  DNS --> L1
  DNS --> L2
  DNS --> L3
  subgraph D["Data layer on VMs, WireGuard"]
    PG[("Patroni: primary + 2 replicas")]
    GA["Garage: 3 zones"]
    ET["etcd: 3 members"]
  end
  P1 --> PG
  P2 --> PG
  P3 --> PG
  P1 --> GA
  P2 --> GA
  P3 --> GA
Architecture setup L

Why not CloudNativePG across clusters?

CloudNativePG can run replica clusters in other Kubernetes clusters, but promotion and demotion between clusters are declarative (field primary, promotionToken), i.e. not automatic. For automatic provider failover, Patroni with etcd across three sites is the more direct solution. [52]

One cluster across all providers?

Talos offers KubeSpan, a WireGuard full mesh across sites, and k3s a flannel backend wireguard-native. The control plane (etcd), however, is sensitive to WAN latency. Several small clusters are simpler to run and fail independently. [51][49][50]

Step by step

  1. Build the data layer like setup M (three VMs with Patroni, etcd, Garage).
  2. One k3s or Talos cluster per provider; network link to the data layer via WireGuard (or KubeSpan).
  3. Inbound ports: service of type LoadBalancer with externalTrafficPolicy: Local (as in the repo) so the sender IP is preserved for SPF/DMARC; with a load balancer that speaks PROXY protocol use Cluster__EnableProxyProtocol=true instead. The Hetzner Cloud Load Balancer supports PROXY protocol and any TCP service. [19]
  4. Outbound port 25: pod egress through a fixed IP with matching PTR and SPF entry (egress gateway, as described in deploy/kubernetes/README.md).
  5. Helm values per provider, rollout via pipeline one after another (chapter 07). HPA (2 to 10 pods at 70 % CPU) and PodDisruptionBudget ship with the repo.
Helm: k8s/values-hetzner.yaml
# values-hetzner.yaml (one file per provider cluster; netcup/strato analogue)
# Helm chart: deploy/helm/nextxch in the nextxch repository.
replicaCount: 3
mail:
  hostname: mail.example.com
  defaultDomain: example.com
postgres:
  # Patroni data layer outside Kubernetes, reached through WireGuard / KubeSpan
  connectionString: "Host=10.88.0.1,10.88.0.2,10.88.0.3;Database=nextxch;Username=nextxch;Password=<from secret>;Target Session Attributes=primary"
s3:
  endpointUrl: "http://garage.nextxch-data.svc:3900"   # Garage zone of this provider
  bucketName: "nextxch"

07 · Deploy and update from Git

Everything important is a file in the repository: compose file or Helm values, Ansible inventory, DNS failover script. The pipeline always rolls out one site only and waits until /health/ready reports "ready" again before moving on. Secrets (licence, passwords, API tokens, SSH keys) live only in the GitHub/GitLab secret stores or in the .env on the server, never in the repository.

flowchart TB
  G["git push to main"] --> V["Validate: compose config / helm lint"]
  V --> A["Roll out site A"]
  A --> HA{"/health/ready = ready?"}
  HA -->|"yes"| B["Roll out site B"]
  HA -->|"no"| X["Abort, B stays on old version"]
  B --> HB{"/health/ready = ready?"}
  HB -->|"yes"| C["Roll out site C"]
  HB -->|"no"| X
  C --> OK["Done"]
Deployment pipeline
GitHub Actions: Compose-Standorte
# .github/workflows/deploy.yml: rolling update of the compose sites, one site at a time.
name: deploy-nextxch
on:
  push:
    branches: [main]
    paths: ["sites/**", "compose/**"]
  workflow_dispatch:

concurrency: { group: deploy-nextxch, cancel-in-progress: false }

jobs:
  deploy:
    runs-on: ubuntu-latest
    strategy:
      max-parallel: 1            # never update two sites at once
      matrix:
        site: [hetzner-a, netcup-b]
    environment: ${{ matrix.site }}
    steps:
      - uses: actions/checkout@v4
      - name: Write SSH key
        run: |
          install -m 700 -d ~/.ssh
          echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
          echo "${{ vars.HOST_KEY }}" >> ~/.ssh/known_hosts
      - name: Upload compose file and roll the container
        env:
          HOST: ${{ vars.SSH_HOST }}
        run: |
          scp compose/docker-compose.yml "deploy@${HOST}:/opt/nextxch/docker-compose.yml"
          scp "sites/${{ matrix.site }}.env.tag" "deploy@${HOST}:/opt/nextxch/tag.env"
          ssh "deploy@${HOST}" 'cd /opt/nextxch && docker compose --env-file .env --env-file tag.env pull && docker compose --env-file .env --env-file tag.env up -d'
      - name: Wait until the node is ready again
        env:
          HOST: ${{ vars.SSH_HOST }}
        run: |
          for i in $(seq 1 30); do
            ssh "deploy@${HOST}" 'curl -fsS http://127.0.0.1/health/ready' && exit 0
            sleep 10
          done
          echo "node did not become ready" >&2; exit 1
GitLab CI
# .gitlab-ci.yml: the same rolling update with GitLab CI
stages: [validate, deploy-a, deploy-b]

variables:
  NEXTXCH_TAG: "latest"     # pin a released version in production

validate:
  stage: validate
  image: docker:27
  script:
    - docker compose -f compose/docker-compose.yml config -q

.deploy: &deploy
  image: alpine:3.22
  before_script:
    - apk add --no-cache openssh-client curl
    - install -m 700 -d ~/.ssh && echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
    - echo "$HOST_KEY" >> ~/.ssh/known_hosts
  script:
    - scp compose/docker-compose.yml "deploy@$SSH_HOST:/opt/nextxch/"
    - ssh "deploy@$SSH_HOST" "cd /opt/nextxch && NEXTXCH_TAG=$NEXTXCH_TAG docker compose pull && NEXTXCH_TAG=$NEXTXCH_TAG docker compose up -d"
    - for i in $(seq 1 30); do ssh "deploy@$SSH_HOST" 'curl -fsS http://127.0.0.1/health/ready' && exit 0; sleep 10; done; exit 1
  resource_group: production   # serializes deployments
  rules: [ { if: '$CI_COMMIT_BRANCH == "main"' } ]

deploy-hetzner-a:
  <<: *deploy
  stage: deploy-a
  environment: { name: hetzner-a }

deploy-netcup-b:
  <<: *deploy
  stage: deploy-b
  environment: { name: netcup-b }
GitHub Actions: Kubernetes (Setup L)
# .github/workflows/deploy-k8s.yml: one Kubernetes cluster per provider, rolled one after another.
name: deploy-nextxch-k8s
on:
  push: { branches: [main], paths: ["k8s/**"] }
  workflow_dispatch:

concurrency: { group: deploy-k8s, cancel-in-progress: false }

jobs:
  helm:
    runs-on: ubuntu-latest
    strategy:
      max-parallel: 1
      matrix:
        cluster: [hetzner, netcup, strato]
    environment: k8s-${{ matrix.cluster }}
    steps:
      - uses: actions/checkout@v4
      - uses: azure/setup-helm@v4
      - name: Kubeconfig
        run: |
          mkdir -p ~/.kube
          echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
          chmod 600 ~/.kube/config
      - name: Helm upgrade (waits for readiness probes on /health/ready)
        run: |
          helm upgrade --install nextxch ./deploy/helm/nextxch \
            --namespace nextxch --create-namespace \
            -f k8s/values-common.yaml -f k8s/values-${{ matrix.cluster }}.yaml \
            --set image.tag=${{ vars.NEXTXCH_TAG }} \
            --wait --timeout 10m --atomic
The failover job does not belong in CI
Scheduled CI runs are neither precise to the second nor guaranteed. DNS failover therefore runs on the sites themselves: as the built-in agent in nextxch or as a systemd service with the script (chapter 04), which the pipeline only rolls out.
The mail configuration from Git too
Domains, mailboxes, lists, rules, SMTP and the other settings live in the repository as YAML too: nextxch-cli config plan comments the pull request with the plan, config apply --prune --yes applies it after the merge. Guide with ready GitHub Actions and GitLab CI files: Configuration as code (GitOps).

08 · Failure scenarios: what exactly happens

Hetzner (site A, current primary) goes completely offline

sequenceDiagram
  participant S as Remote mail server
  participant A as Site A (Hetzner)
  participant B as Site B (netcup)
  participant C as Witness C
  participant D as DNS
  Note over A: Provider outage
  S->>A: SMTP to mx1
  A--xS: no connection
  S->>B: SMTP to mx2 (or retry)
  B--xS: without a primary the session fails, sender retries later
  Note over B,C: etcd majority B + C, leader key of A expires (ttl 30 s)
  B->>B: Patroni promotes PostgreSQL B to primary
  B->>B: nextxch reconnects (Target Session Attributes=primary)
  S->>B: retry
  B-->>S: 250 OK (commit synchronous or asynchronous)
  C->>D: failover job removes IP A from mail.example.com
  Note over D: clients follow after the TTL expires
Sequence when site A fails
TimeWhat happensWhat users notice
0 sA is gone. Inbound mail to mx1 fails, senders use mx2 (equal preference) or retry later. [1]Clients on A lose their connection.
up to about 30 sA's leader key in etcd expires after ttl = 30 s; B and C hold the majority. [53]Node B returns 503 on /health/ready, writes wait.
about 30 to 60 sPatroni promotes B (loop_wait 10 s). nextxch on B finds the new primary through the multi-host connection string (recheck 5 to 10 s). [63]B accepts mail again.
about 60 to 90 sDNS failover (agent: 3 failed rounds × 30 s, script: 3 × 20 s) removes IP A from mail.example.com.New connections go to B once the TTL (60 s) has expired.
1 to 5 minResolvers with their own minimum TTLs and clients with DNS caches follow. Garage continues with zones B and C (quorum 2 of 3). [61]Clients reconnect; webmail sessions stay valid (signed, stateless sessions).

RPO

With synchronous_mode, PostgreSQL acknowledges a commit only once the second site has it; a message acknowledged with 250 OK then exists at two sites. The price: every commit waits at least one round trip between the sites. Without synchronous_mode the last seconds before the outage may be missing. With synchronous_mode_strict: false, Patroni falls back to asynchronous when no replica is left, so mail keeps being accepted. [56][54]

RTO

Database about 30 to 60 s (Patroni defaults), DNS switch about 1 min plus TTL, inbound mail without waiting via the second MX or the sender's retry. These times are derived from the defaults, not measured: verify in your own test (hard-stop a site).

netcup (site B, replica) goes offline

The primary on A keeps running. Without a synchronous partner Patroni switches to asynchronous (strict = false), the failover job removes IP B, inbound mail flows via mx1. Garage continues with A and C.

Only the link between A and B is down

The side that still reaches witness C keeps the etcd majority and stays or becomes primary. The other side loses its majority, Patroni demotes a primary there (failsafe_mode only applies if it can still reach all members), and DNS failover does not act there: the built-in agent reaches no primary and does not get the leader lock, the script does not get the etcd lock. No split brain occurs. [55]

Failback

sequenceDiagram
  participant A as Site A
  participant B as Site B (primary)
  participant G as Garage
  participant D as DNS job
  A->>A: server back online, WireGuard up
  A->>B: Patroni: pg_rewind, then replica of B
  G->>A: missing objects are re-replicated
  A->>A: /health/ready = ready
  D->>D: successful checks
  D->>A: IP A back in mail.example.com
  Note over A,B: optional, planned: patronictl switchover back to A
Return of site A

When A returns, B stays primary. Patroni brings A back as a replica with pg_rewind (use_pg_rewind: true), Garage resyncs the missing objects, the failover job re-adds IP A as soon as /health/ready reports "ready" there. Moving the primary back is not required; if desired, do it in a planned way with patronictl switchover at a quiet time.

09 · Backup, restore and GoBD

Replication protects against outages, not against mistakes: an accidentally deleted mailbox is gone at all sites at once. So add backups following the 3-2-1 rule: three copies, two media types, one offsite. [59]

flowchart LR
  PG[("PostgreSQL primary or replica")] -->|"pgBackRest, continuous WAL"| R1["Repo 1: Hetzner Storage Box (SFTP)"]
  PG -->|"pgBackRest"| R2["Repo 2: S3 at another provider"]
  GA["Garage (3 zones)"] -->|"rclone copy hourly"| AR["Object Storage with Object Lock (GoBD)"]
  GA -->|"rclone copy"| R1
  R1 --> T["Quarterly restore test"]
  R2 --> T
Backup flow
  • PostgreSQL: pgBackRest with continuous WAL archiving (point-in-time recovery). Repository 1 on a Hetzner Storage Box via SFTP (pgBackRest supports SFTP since 2.46; the Storage Box offers SFTP, SCP, rsync, BorgBackup, Restic, Rclone, SMB, WebDAV), repository 2 on S3 at another provider. Both encrypted. [58][15]
  • Blobs: the .eml objects are named by SHA-256 and never modified. rclone copy (not sync, so it never deletes) is enough as an hourly copy.
  • GoBD archive: Hetzner Object Storage supports Object Lock in GOVERNANCE and COMPLIANCE mode plus versioning; Object Lock must be enabled when the bucket is created. Create the bucket with a default retention (compliance), then every copied object is locked automatically. Alternatively let nextxch write directly to this bucket (S3 or Hybrid backend), but then blob availability depends on Hetzner. In that case nextxch creates a missing bucket with Object Lock at startup as soon as a WORM mode is configured (or Storage:S3:ObjectLockEnabled); "Check S3 bucket" in the GoBD archive shows reachability, versioning, Object Lock mode and default retention. [18][17]
  • Test restores: at least quarterly restore pgBackRest into a test VM and open samples from the blob copy.
pgBackRest: /etc/pgbackrest/pgbackrest.conf
# /etc/pgbackrest/pgbackrest.conf on every PostgreSQL site.
# Repo 1: Hetzner Storage Box over SFTP (pgBackRest 2.46+), repo 2: S3 at another provider.
[global]
repo1-type=sftp
repo1-path=/pgbackrest
repo1-sftp-host=uXXXXXX.your-storagebox.de
repo1-sftp-host-port=23
repo1-sftp-host-user=uXXXXXX
repo1-sftp-private-key-file=/var/lib/postgresql/.ssh/id_ed25519
repo1-sftp-host-key-hash-type=sha256
repo1-retention-full=2
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<long random secret, stored outside the repo>

repo2-type=s3
repo2-path=/pgbackrest
repo2-s3-endpoint=<s3 endpoint of the second provider>
repo2-s3-bucket=nextxch-pg-backup
repo2-s3-region=<region>
repo2-s3-uri-style=path
repo2-retention-full=4
repo2-cipher-type=aes-256-cbc
repo2-cipher-pass=<second secret>

archive-async=y
process-max=2
start-fast=y

[nextxch]
pg1-path=/var/lib/postgresql/18/main
pg1-port=5432

# Weekly full, daily differential (cron on every node; pgBackRest runs only where it can,
# use --repo and the "backup-standby" option to take backups from a replica).
#   0 2 * * 0  pgbackrest --stanza=nextxch --type=full backup
#   0 2 * * 1-6 pgbackrest --stanza=nextxch --type=diff backup
rclone: blob copy and GoBD bucket
# Blob copy (all .eml objects) from Garage to an independent target, every hour.
# ~/.config/rclone/rclone.conf (keys are placeholders)
[garage]
type = s3
provider = Other
endpoint = http://127.0.0.1:3900
region = garage
access_key_id = <garage key>
secret_access_key = <garage secret>

[archive]
type = s3
provider = Other
endpoint = <object storage endpoint>
access_key_id = <key>
secret_access_key = <secret>

# Content-addressed (SHA-256 names, never modified): "copy" is enough and never deletes.
#   rclone copy garage:nextxch archive:nextxch-archive --immutable --transfers 8 --checksum
# For a GoBD copy create the target bucket WITH Object Lock and a default retention
# (COMPLIANCE, e.g. 10 years) so every copied object is locked automatically.

10 · Scaling: users, storage, pods

flowchart LR
  S1["Start: 2 sites + witness"] --> S2["More mailboxes: add nextxch nodes per site"]
  S2 --> S3["DB grows: scale PostgreSQL VMs vertically (RAM, NVMe)"]
  S3 --> S4["More storage: add Garage nodes per zone"]
  S4 --> S5["Many mailboxes: Kubernetes per provider, HPA"]
Scaling path
ComponentScalesNote
nextxch nodes / podshorizontallyStateless; more nodes per site or more pods (HPA). No sticky sessions required.
PostgreSQL primaryverticallyAll writes go to one primary. More RAM and faster disks (IOPS) instead of more nodes; shared_buffers start value 25 % of RAM. nextxch does not use read replicas. [57]
Blob storehorizontallyMore Garage nodes per zone with capacity (garage layout assign -c), data is rebalanced. SHA-256 deduplication and Zstd compression reduce the need.
SMTPvia DNSMore MX records with equal preference spread the load; the outbound queue spreads across all nodes via SKIP LOCKED.
IMAPvia DNS / LBLong-lived IDLE connections stay on their node; new mail from other nodes arrives through LISTEN/NOTIFY. After adding a node, load spreads only as clients reconnect. [7]
HTTPS (webmail, ActiveSync, JMAP)via DNS / LBStateless HMAC sessions; any request may hit any node.

11 · Cost: where to save, where not

Save

  • Shared vCPU plans for nextxch and witness; dedicated cores only once database CPU load shows it.
  • 12-month term at netcup (13 % below the monthly price according to the price page). [22]
  • DNS failover with the built-in agent (or your own script) instead of a paid load balancer.
  • Zstd compression (Storage:Compression) and SHA-256 deduplication for blobs.

Do not save on

  • RAM of the PostgreSQL nodes: everything not cached costs disk IO on every folder view.
  • Disk IOPS for PostgreSQL (NVMe); WAL writes determine the latency of every delivery.
  • The third site: without it there is no safe automatic failover.
  • Tested backups at an independent target.

Stored prices (net per month, as of 2 Nov 2026). netcup and STRATO list gross prices incl. 19 % VAT, divided by 1.19 here. For Hetzner €0.50 for the primary IPv4 is added. On the check date Hetzner showed all cloud plans as "currently unavailable"; whether that was stock or display could not be determined. The price for Hetzner volumes could not be read from the official pages, so it is missing from the calculator. Prices change: check with the provider before ordering. [12][10][22][23][30][15][16]

ProviderPlanvCPURAMDisk€ net / month
Hetzner CloudCX2324 GB40 GB€6.49
Hetzner CloudCX3348 GB80 GB€9.49
Hetzner CloudCX43816 GB160 GB€16.99
Hetzner CloudCX531632 GB320 GB€30.49
netcup VPSVPS 500 G12.524 GB64 GB€6.94
netcup VPSVPS 1000 G12.548 GB128 GB€12.18
netcup VPSVPS 2000 G12.5816 GB256 GB€22.62
netcup VPSVPS 4000 G12.51232 GB512 GB€38.12
netcup VPSVPS 8000 G12.51664 GB1024 GB€56.39
netcup Root ServerRS 1000 G12.548 GB128 GB€18.26
netcup Root ServerRS 2000 G12.5816 GB256 GB€34.20
netcup Root ServerRS 4000 G12.51232 GB512 GB€65.40
netcup Root ServerRS 8000 G12.51664 GB1024 GB€124.72
netcup Root ServerRS 12000 G12.52096 GB1536 GB€182.26
netcup Root ServerRS 16000 G12.524128 GB2048 GB€243.24
STRATO VPSVPS S12 GB60 GB€3.36
STRATO VPSVPS M24 GB120 GB€8.40
STRATO VPSVPS L48 GB240 GB€16.81
STRATO VPSVPS XL816 GB480 GB€32.77
STRATO VPSVPS XXL1224 GB720 GB€47.06
Hetzner Storage BoxBX11--1 TB€3.20
Hetzner Storage BoxBX21--5 TB€10.90
Hetzner Storage BoxBX31--10 TB€20.80
Hetzner Storage BoxBX41--20 TB€40.60
Hetzner Object Storagebase price incl. 1 TB storage + 1 TB egress--1 TB€6.49

12 · Calculator: resources and cost

Runs entirely in the browser, without external services. The assumptions are listed openly below the result.

Assumptions (not measured, please check with your own numbers)
  • Mail volume = mailboxes × avg size × (1 + growth)^years. No deduction for deduplication or compression (conservative).
  • PostgreSQL size = 3 % of the mail volume (metadata, flags, full-text index), at least 2 GB. The real value comes from SELECT pg_database_size('nextxch').
  • RAM for PostgreSQL = 25 % of the DB size, at least 2 GB, rounded up to 2/4/8/16/32/64/96/128 GB. Disk for PostgreSQL = 2 × DB size (data, WAL, headroom).
  • nextxch process: 1 GB + 1 GB per started 1,000 mailboxes (the Kubernetes limit in the repo is 1 GiB per pod). etcd + Garage + OS: 1 GB. Witness: 2 GB RAM. Operating system: 20 GB disk.
  • Garage replication_factor 3: every zone (site or witness) stores the full mail volume.
  • Setup L: at least 2 pods per provider, 1 pod per 1,500 mailboxes, workers with 8 GB; the data VM holds PostgreSQL, etcd and the Garage zone.
  • Backup need = 3 × DB size (2 full backups + WAL) + mail volume; target: smallest fitting Hetzner Storage Box.
  • Licence prices from this website's current price catalogue (the step price applies to all mailboxes, annual price / 12). Cloudflare is shown in USD and not added to the euro total.

13 · Adding another provider (example STRATO)

  1. Before ordering: can PTR be set? STRATO: reverse DNS for VPS in the customer login under domains, an A/AAAA record must exist first, takes up to 24 h. Port 25: for root servers STRATO documents outbound as blocked with unblocking via support; for VPS no rule is documented. STRATO offers no failover IP. [31][32][30]
  2. Add the new host to the Ansible inventory with its own WireGuard IP and Garage zone, add the peer on all sites, run the playbook.
  3. etcd: add the member with etcdctl member add and start it with ETCD_INITIAL_CLUSTER_STATE=existing. With four database sites keep the number of etcd members odd. [48]
  4. Patroni starts as a replica and replicates automatically. Garage: garage layout assign with the new zone, garage layout apply.
  5. DNS: add the IP to SPF, a new MX with equal preference, the node to the DNS failover list (agent or script), TLSA record for the new MX name, EHLO name and PTR of the new node, extend the certificate with the name. The MTA-STS policy picks up the new MX automatically (automatic MX list); then publish the new _mta-sts TXT value from the DNS advisor.
  6. Add the site to the pipeline matrix and test an outage of the new site once.

14 · Limits and gaps (honestly)

Found in the code while writing this guide. Four gaps are closed by now: MTA-STS with several MX names, EHLO name per node, automatically created Object Lock bucket and the built-in DNS failover agent. The guide works around the rest as described.

GapImpactWorkaround
DNS failover agent (closed)Built in, opt-in: health checks from every node, only the cluster leader changes A/AAAA via Hetzner DNS or Cloudflare.Other DNS providers (e.g. deSEC): script under etcd lock (chapter 04).
MTA-STS with several MX (closed)The per-domain policy names all MX names (DNS MX, server and node names, or an own list with wildcards); the policy id follows the list.After every MX change publish the _mta-sts TXT value from the DNS advisor.
EHLO name per node (closed)Smtp:HeloHostname or a per-node setting in /admin/, with a PTR check per node; the server host name stays cluster-wide.-
Only one S3 targetNo dual write into two S3 stores at different providers; the Object Lock features require an S3 with Object Lock.Garage across three sites plus rclone copy into an Object Lock bucket.
Object Lock bucket (closed)With a WORM mode configured, the server creates a missing bucket with Object Lock at startup. An existing bucket without Object Lock is reported as warning and alert, because Object Lock can only be enabled at creation. [18]Check any time with "Check S3 bucket" in the GoBD archive.
No built-in backupThere is no backup service for database and blobs in the server.pgBackRest and rclone (chapter 09).
No read replicasAll queries go to the primary; read replicas are listed in the development plan only as "on request".Scale the PostgreSQL primary vertically.
No measured sizing benchmarkRAM and DB sizes in the calculator are assumptions.Override with your own measurements.

FAQ

Can nextxch run active-active across several providers?

Yes. With Cluster:Enabled=true and PostgreSQL, any number of nextxch nodes share state through LISTEN/NOTIFY, UNLOGGED tables and advisory locks. All nodes must reach the same PostgreSQL primary and the same S3 blob store; across providers this happens over a WireGuard network.

Do I need a load balancer or anycast?

No. Inbound mail only needs several MX records with equal preference (RFC 5321 section 5.1). IMAP, submission and HTTPS need one name with a short TTL whose A records are maintained by a health-check job. Anycast/BGP is possible but complex.

How many sites do I need at minimum?

Three votes: two full sites plus a small witness (etcd, Garage zone, failover checker), or three full sites. With only two sites neither side can safely tell whether the other one failed or only the link broke (split brain).

Is mail lost when a provider goes offline?

Senders keep retrying for at least 4 to 5 days (RFC 5321 section 4.5.4.1). With Patroni synchronous_mode and Garage replication_factor 3, a message acknowledged with 250 OK is already stored at a second site.

15 · Sources

All external facts were checked on 2 Nov 2026 against the following official sources. Points that could not be verified are marked as such in the text.

  1. RFC 5321 (SMTP): 4.1.4 EHLO, 4.5.4.1 retry, 5.1 MX: https://www.rfc-editor.org/rfc/rfc5321.html
  2. RFC 7208 (SPF): 4.6.4 DNS lookup limit, 5.6 ip4/ip6: https://www.rfc-editor.org/rfc/rfc7208.html
  3. RFC 6376 (DKIM): 3.1 selectors: https://www.rfc-editor.org/rfc/rfc6376.html
  4. RFC 8461 (MTA-STS): 3.2, 4.1 mx patterns: https://www.rfc-editor.org/rfc/rfc8461.html
  5. RFC 7672 (DANE for SMTP): 3.1 TLSA 3 1 1: https://www.rfc-editor.org/rfc/rfc7672.html
  6. RFC 8314 (implicit TLS 465/993/995): https://www.rfc-editor.org/rfc/rfc8314.html
  7. RFC 2177 (IMAP IDLE, re-issue every 29 minutes): https://www.rfc-editor.org/rfc/rfc2177.html
  8. Google: Email sender guidelines (forward and reverse DNS): https://support.google.com/a/answer/81126
  9. Hetzner Docs: Floating IPs: https://docs.hetzner.com/cloud/floating-ips/overview
  10. Hetzner Docs: IPv4 pricing: https://docs.hetzner.com/general/others/ipv4-pricing
  11. Hetzner Docs: Robot failover IPs: https://docs.hetzner.com/robot/dedicated-server/ip/failover
  12. Hetzner Cloud: cost-optimized plans: https://www.hetzner.com/cloud/cost-optimized/
  13. Hetzner Docs: Cloud server FAQ (ports 25/465): https://docs.hetzner.com/cloud/servers/faq/
  14. Hetzner Docs: PTR records: https://docs.hetzner.com/networking/dns/record-types/ptr-record/
  15. Hetzner Storage Box (prices, protocols): https://www.hetzner.com/storage/storage-box/
  16. Hetzner Object Storage (prices): https://www.hetzner.com/storage/object-storage/
  17. Hetzner Docs: Object Storage overview: https://docs.hetzner.com/storage/object-storage/overview
  18. Hetzner Docs: Object Lock and retention: https://docs.hetzner.com/storage/object-storage/howto-protect-objects/protect-object-lock-retention/
  19. Hetzner Docs: Load Balancer FAQ: https://docs.hetzner.com/cloud/load-balancers/faq
  20. Hetzner Docs: Networks FAQ: https://docs.hetzner.com/cloud/networks/faq
  21. Hetzner Docs: DNS (Console and API): https://docs.hetzner.com/networking/dns/overview/
  22. netcup: VPS G12.5 prices: https://www.netcup.com/de/server/vps
  23. netcup: Root Server G12.5 prices: https://www.netcup.com/de/server/root-server
  24. netcup Helpcenter: IP addresses and failover: https://www.netcup.com/en/helpcenter/documentation/server/ip
  25. netcup: server extensions (failover IP prices): https://www.netcup.com/de/server/kvm-server-erweiterungen
  26. netcup Helpcenter: firewall (policy "netcup Mail block"): https://www.netcup.com/en/helpcenter/documentation/server/firewall
  27. netcup Helpcenter: network / rDNS: https://www.netcup.com/en/helpcenter/documentation/server/network-server
  28. netcup: Storage Space (NFS): https://www.netcup.com/en/server/storage-space
  29. netcup Helpcenter: DNS API: https://www.netcup.com/en/helpcenter/documentation/domain/our-api
  30. STRATO: VPS Linux prices: https://www.strato.de/server/linux-vserver/
  31. STRATO FAQ: Reverse DNS for VPS: https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
  32. STRATO FAQ: root server network firewall (port 25): https://www.strato.de/faq/server/wie-stelle-ich-die-netzwerk-firewall-bei-meinem-windows-root-server-ein/
  33. Cloudflare: Load Balancing add-on price: https://www.cloudflare.com/plans/network-cdn/
  34. Cloudflare Docs: Load Balancing limitations (health check interval): https://developers.cloudflare.com/load-balancing/reference/limitations/
  35. Cloudflare Docs: Load Balancing DNS records (MX/SRV Enterprise only): https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
  36. Cloudflare Docs: proxy modes (DNS-only): https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
  37. Cloudflare Docs: Spectrum protocols per plan: https://developers.cloudflare.com/spectrum/protocols-per-plan/
  38. Cloudflare Docs: DNS TTL: https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/
  39. Cloudflare API: edit DNS record: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
  40. deSEC: domains and minimum TTL: https://desec.readthedocs.io/en/latest/dns/domains.html
  41. Akamai/Linode: IP failover (BGP, same data center): https://techdocs.akamai.com/cloud-computing/docs/configure-failover-on-a-compute-instance
  42. Akamai: NodeBalancer pricing: https://www.akamai.com/cloud/pricing/nodebalancers
  43. Vultr: BGP requirements: https://docs.vultr.com/configuring-bgp-on-vultr
  44. RIPE NCC: requesting an AS number: https://www.ripe.net/manage-ips-and-asns/as-numbers/request-an-as-number/
  45. Proxmox VE: Cluster Manager (corosync latency, QDevice): https://pve.proxmox.com/wiki/Cluster_Manager
  46. Proxmox Backup Server: datastores (incl. S3): https://pbs.proxmox.com/docs/storage.html
  47. WireGuard: https://www.wireguard.com/
  48. k3s: HA with embedded etcd: https://docs.k3s.io/datastore/ha-embedded
  49. k3s: distributed hybrid or multicloud cluster: https://docs.k3s.io/networking/distributed-multicloud
  50. etcd: tuning (heartbeat, election timeout): https://etcd.io/docs/v3.5/tuning/
  51. Talos Linux: KubeSpan: https://docs.siderolabs.com/talos/v1.11/networking/kubespan
  52. CloudNativePG: replica clusters: https://cloudnative-pg.io/docs/current/replica_cluster
  53. Patroni: dynamic configuration (ttl, loop_wait, retry_timeout): https://patroni.readthedocs.io/en/latest/dynamic_configuration.html
  54. Patroni: replication modes (synchronous_mode, strict): https://patroni.readthedocs.io/en/latest/replication_modes.html
  55. Patroni: DCS failsafe mode: https://patroni.readthedocs.io/en/latest/dcs_failsafe_mode.html
  56. PostgreSQL 18: synchronous replication: https://www.postgresql.org/docs/current/warm-standby.html#SYNCHRONOUS-REPLICATION
  57. PostgreSQL 18: shared_buffers: https://www.postgresql.org/docs/current/runtime-config-resource.html
  58. pgBackRest (S3 and SFTP repositories): https://pgbackrest.org/
  59. CISA: Data Backup Options (3-2-1): https://www.cisa.gov/sites/default/files/publications/data_backup_options.pdf
  60. Garage: project page (multi-datacenter, 200 ms / 50 Mbps): https://garagehq.deuxfleurs.fr/
  61. Garage: configuration (replication_factor, quorums): https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
  62. Garage: S3 compatibility (no Object Lock, no versioning): https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/
  63. Npgsql: failover and load balancing (multi-host): https://www.npgsql.org/doc/failover-and-load-balancing.html
  64. Mermaid 12.2.0 (MIT), vendored on this site: https://www.npmjs.com/package/mermaid