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.
| Capability | How | Source in code |
|---|---|---|
| Cluster backplane without Redis | PostgreSQL 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 heartbeat | src/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs |
| Leader tasks | pg_try_advisory_lock: exactly one node renews ACME certificates, sends the licence heartbeat, syncs LDAP, sends calendar reminders | AcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs |
| SMTP queue across nodes | FOR UPDATE SKIP LOCKED: each node claims different queue items, no double delivery | src/Nextxch.Storage/Postgres/PostgresMailStore.cs |
| Cluster-capable databases | PostgreSQL (default for clusters) and SQL Server (sp_getapplock); SQLite single node only | src/Nextxch.Storage/StorageServiceCollectionExtensions.cs |
| Blob store | FileSystem, S3 (own SigV4 client with Content-MD5) or Hybrid (local NVMe cache + S3). Object Lock modes Normal, Governance, Compliance and legal hold | src/Nextxch.Storage/Blobs/S3BlobStore.cs, HybridBlobStore.cs |
| Certificates & DKIM | ACME 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 IP | PROXY protocol v1/v2 for SMTP, IMAP, ManageSieve (Cluster:EnableProxyProtocol) and X-Forwarded-For for HTTP from TrustedProxyCidrs | src/Nextxch.Core/Abstractions/IClusterCoordinator.cs |
| Deploy artefacts | docker-compose.yml, Kubernetes manifests (3 replicas, HPA 2 to 10, PDB, probes), Helm chart, deb/rpm, Windows MSI | deploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/ |
| Licence | Active mailboxes are counted, not nodes or pods; licence state is shared through cluster_kv | src/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).
/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]
; 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)
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]# 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
# 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.
| Option | Scope | Cost | Assessment |
|---|---|---|---|
| Several MX with equal preference | across providers | 0 € | Mandatory for inbound mail, standardised [1] |
| Own health-check job + DNS API (Cloudflare, Hetzner, deSEC, netcup) | across providers | 0 € | 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 providers | from USD 5/month | Health 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 Spectrum | across providers | Enterprise | Arbitrary 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 zone | IPv4 €3.00, IPv6 €1.00/month | Only inside Hetzner [9] |
| Hetzner failover IP (Robot, dedicated) | Hetzner data centres | €4.20/month + €4.90 setup | Switch via Robot API in 90 to 110 s, only inside Hetzner [11] |
| netcup failover IP | same netcup location | €5.00/month incl. VAT | Re-route in SCP any time for free, only inside netcup [24][25] |
| Akamai/Linode IP sharing | one data centre, two Linodes | from USD 5 (Nanode) | BGP-based (lelastic), same data centre only; NodeBalancer Basic USD 10, TCP/HTTP [41][42] |
| Anycast / own BGP | global | high | Own 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] |
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
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]
Step by step
- 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]
- Set PTR: IP A to mx1.example.com (Hetzner Console), IP B to mx2.example.com (netcup SCP). [14][27]
- Base install via cloud-init (Debian 13): deploy user, Docker, WireGuard, no password login.
- WireGuard mesh 10.88.0.0/24 between A, B, C; open UDP 51820 only for the peer IPs. [47]
- etcd on all three (election timeout at least 10 × RTT), then Patroni + PostgreSQL 18 on A and B. [50][53]
- Garage on all three, apply a layout with three zones, create bucket and key. [61]
- 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.
- 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.
- Backups as in chapter 09 and test a restore. Then rehearse an outage: hard-stop site A and follow chapter 08.
#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 ]
# /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.
# /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.).
# /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
# /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
# 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.
# 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.
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]
#!/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
# /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"]
Step by step
- Three VMs (or Proxmox guests) at three providers, same Debian version, PTR to mx1/mx2/mx3 each.
- Ansible inventory with three hosts, one playbook for all (WireGuard, etcd, Patroni, Garage, nextxch).
- Patroni config from setup S with a third host; synchronous_mode: on, with three databases optionally quorum. [54]
- Connection string with three hosts, Garage layout with three zones, three MX records, three IPs in SPF and in the DNS failover node list.
- 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]
# 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 }
# 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
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]
Step by step
- Build the data layer like setup M (three VMs with Patroni, etcd, Garage).
- One k3s or Talos cluster per provider; network link to the data layer via WireGuard (or KubeSpan).
- 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 useCluster__EnableProxyProtocol=trueinstead. The Hetzner Cloud Load Balancer supports PROXY protocol and any TCP service. [19] - Outbound port 25: pod egress through a fixed IP with matching PTR and SPF entry (egress gateway, as described in deploy/kubernetes/README.md).
- 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.
# 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"]
# .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.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/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
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
| Time | What happens | What users notice |
|---|---|---|
| 0 s | A 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 s | A'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 s | Patroni 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 s | DNS 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 min | Resolvers 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
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
- 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.
# /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
# 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"]
| Component | Scales | Note |
|---|---|---|
| nextxch nodes / pods | horizontally | Stateless; more nodes per site or more pods (HPA). No sticky sessions required. |
| PostgreSQL primary | vertically | All 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 store | horizontally | More Garage nodes per zone with capacity (garage layout assign -c), data is rebalanced. SHA-256 deduplication and Zstd compression reduce the need. |
| SMTP | via DNS | More MX records with equal preference spread the load; the outbound queue spreads across all nodes via SKIP LOCKED. |
| IMAP | via DNS / LB | Long-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 / LB | Stateless 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]
| Provider | Plan | vCPU | RAM | Disk | € net / month |
|---|---|---|---|---|---|
| Hetzner Cloud | CX23 | 2 | 4 GB | 40 GB | €6.49 |
| Hetzner Cloud | CX33 | 4 | 8 GB | 80 GB | €9.49 |
| Hetzner Cloud | CX43 | 8 | 16 GB | 160 GB | €16.99 |
| Hetzner Cloud | CX53 | 16 | 32 GB | 320 GB | €30.49 |
| netcup VPS | VPS 500 G12.5 | 2 | 4 GB | 64 GB | €6.94 |
| netcup VPS | VPS 1000 G12.5 | 4 | 8 GB | 128 GB | €12.18 |
| netcup VPS | VPS 2000 G12.5 | 8 | 16 GB | 256 GB | €22.62 |
| netcup VPS | VPS 4000 G12.5 | 12 | 32 GB | 512 GB | €38.12 |
| netcup VPS | VPS 8000 G12.5 | 16 | 64 GB | 1024 GB | €56.39 |
| netcup Root Server | RS 1000 G12.5 | 4 | 8 GB | 128 GB | €18.26 |
| netcup Root Server | RS 2000 G12.5 | 8 | 16 GB | 256 GB | €34.20 |
| netcup Root Server | RS 4000 G12.5 | 12 | 32 GB | 512 GB | €65.40 |
| netcup Root Server | RS 8000 G12.5 | 16 | 64 GB | 1024 GB | €124.72 |
| netcup Root Server | RS 12000 G12.5 | 20 | 96 GB | 1536 GB | €182.26 |
| netcup Root Server | RS 16000 G12.5 | 24 | 128 GB | 2048 GB | €243.24 |
| STRATO VPS | VPS S | 1 | 2 GB | 60 GB | €3.36 |
| STRATO VPS | VPS M | 2 | 4 GB | 120 GB | €8.40 |
| STRATO VPS | VPS L | 4 | 8 GB | 240 GB | €16.81 |
| STRATO VPS | VPS XL | 8 | 16 GB | 480 GB | €32.77 |
| STRATO VPS | VPS XXL | 12 | 24 GB | 720 GB | €47.06 |
| Hetzner Storage Box | BX11 | - | - | 1 TB | €3.20 |
| Hetzner Storage Box | BX21 | - | - | 5 TB | €10.90 |
| Hetzner Storage Box | BX31 | - | - | 10 TB | €20.80 |
| Hetzner Storage Box | BX41 | - | - | 20 TB | €40.60 |
| Hetzner Object Storage | base 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)
- 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]
- 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.
- etcd: add the member with
etcdctl member addand start it with ETCD_INITIAL_CLUSTER_STATE=existing. With four database sites keep the number of etcd members odd. [48] - Patroni starts as a replica and replicates automatically. Garage:
garage layout assignwith the new zone,garage layout apply. - 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.
- 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.
| Gap | Impact | Workaround |
|---|---|---|
| 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 target | No 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 backup | There is no backup service for database and blobs in the server. | pgBackRest and rclone (chapter 09). |
| No read replicas | All 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 benchmark | RAM 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.
- RFC 5321 (SMTP): 4.1.4 EHLO, 4.5.4.1 retry, 5.1 MX: https://www.rfc-editor.org/rfc/rfc5321.html
- RFC 7208 (SPF): 4.6.4 DNS lookup limit, 5.6 ip4/ip6: https://www.rfc-editor.org/rfc/rfc7208.html
- RFC 6376 (DKIM): 3.1 selectors: https://www.rfc-editor.org/rfc/rfc6376.html
- RFC 8461 (MTA-STS): 3.2, 4.1 mx patterns: https://www.rfc-editor.org/rfc/rfc8461.html
- RFC 7672 (DANE for SMTP): 3.1 TLSA 3 1 1: https://www.rfc-editor.org/rfc/rfc7672.html
- RFC 8314 (implicit TLS 465/993/995): https://www.rfc-editor.org/rfc/rfc8314.html
- RFC 2177 (IMAP IDLE, re-issue every 29 minutes): https://www.rfc-editor.org/rfc/rfc2177.html
- Google: Email sender guidelines (forward and reverse DNS): https://support.google.com/a/answer/81126
- Hetzner Docs: Floating IPs: https://docs.hetzner.com/cloud/floating-ips/overview
- Hetzner Docs: IPv4 pricing: https://docs.hetzner.com/general/others/ipv4-pricing
- Hetzner Docs: Robot failover IPs: https://docs.hetzner.com/robot/dedicated-server/ip/failover
- Hetzner Cloud: cost-optimized plans: https://www.hetzner.com/cloud/cost-optimized/
- Hetzner Docs: Cloud server FAQ (ports 25/465): https://docs.hetzner.com/cloud/servers/faq/
- Hetzner Docs: PTR records: https://docs.hetzner.com/networking/dns/record-types/ptr-record/
- Hetzner Storage Box (prices, protocols): https://www.hetzner.com/storage/storage-box/
- Hetzner Object Storage (prices): https://www.hetzner.com/storage/object-storage/
- Hetzner Docs: Object Storage overview: https://docs.hetzner.com/storage/object-storage/overview
- Hetzner Docs: Object Lock and retention: https://docs.hetzner.com/storage/object-storage/howto-protect-objects/protect-object-lock-retention/
- Hetzner Docs: Load Balancer FAQ: https://docs.hetzner.com/cloud/load-balancers/faq
- Hetzner Docs: Networks FAQ: https://docs.hetzner.com/cloud/networks/faq
- Hetzner Docs: DNS (Console and API): https://docs.hetzner.com/networking/dns/overview/
- netcup: VPS G12.5 prices: https://www.netcup.com/de/server/vps
- netcup: Root Server G12.5 prices: https://www.netcup.com/de/server/root-server
- netcup Helpcenter: IP addresses and failover: https://www.netcup.com/en/helpcenter/documentation/server/ip
- netcup: server extensions (failover IP prices): https://www.netcup.com/de/server/kvm-server-erweiterungen
- netcup Helpcenter: firewall (policy "netcup Mail block"): https://www.netcup.com/en/helpcenter/documentation/server/firewall
- netcup Helpcenter: network / rDNS: https://www.netcup.com/en/helpcenter/documentation/server/network-server
- netcup: Storage Space (NFS): https://www.netcup.com/en/server/storage-space
- netcup Helpcenter: DNS API: https://www.netcup.com/en/helpcenter/documentation/domain/our-api
- STRATO: VPS Linux prices: https://www.strato.de/server/linux-vserver/
- STRATO FAQ: Reverse DNS for VPS: https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
- 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/
- Cloudflare: Load Balancing add-on price: https://www.cloudflare.com/plans/network-cdn/
- Cloudflare Docs: Load Balancing limitations (health check interval): https://developers.cloudflare.com/load-balancing/reference/limitations/
- Cloudflare Docs: Load Balancing DNS records (MX/SRV Enterprise only): https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
- Cloudflare Docs: proxy modes (DNS-only): https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
- Cloudflare Docs: Spectrum protocols per plan: https://developers.cloudflare.com/spectrum/protocols-per-plan/
- Cloudflare Docs: DNS TTL: https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/
- Cloudflare API: edit DNS record: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
- deSEC: domains and minimum TTL: https://desec.readthedocs.io/en/latest/dns/domains.html
- Akamai/Linode: IP failover (BGP, same data center): https://techdocs.akamai.com/cloud-computing/docs/configure-failover-on-a-compute-instance
- Akamai: NodeBalancer pricing: https://www.akamai.com/cloud/pricing/nodebalancers
- Vultr: BGP requirements: https://docs.vultr.com/configuring-bgp-on-vultr
- RIPE NCC: requesting an AS number: https://www.ripe.net/manage-ips-and-asns/as-numbers/request-an-as-number/
- Proxmox VE: Cluster Manager (corosync latency, QDevice): https://pve.proxmox.com/wiki/Cluster_Manager
- Proxmox Backup Server: datastores (incl. S3): https://pbs.proxmox.com/docs/storage.html
- WireGuard: https://www.wireguard.com/
- k3s: HA with embedded etcd: https://docs.k3s.io/datastore/ha-embedded
- k3s: distributed hybrid or multicloud cluster: https://docs.k3s.io/networking/distributed-multicloud
- etcd: tuning (heartbeat, election timeout): https://etcd.io/docs/v3.5/tuning/
- Talos Linux: KubeSpan: https://docs.siderolabs.com/talos/v1.11/networking/kubespan
- CloudNativePG: replica clusters: https://cloudnative-pg.io/docs/current/replica_cluster
- Patroni: dynamic configuration (ttl, loop_wait, retry_timeout): https://patroni.readthedocs.io/en/latest/dynamic_configuration.html
- Patroni: replication modes (synchronous_mode, strict): https://patroni.readthedocs.io/en/latest/replication_modes.html
- Patroni: DCS failsafe mode: https://patroni.readthedocs.io/en/latest/dcs_failsafe_mode.html
- PostgreSQL 18: synchronous replication: https://www.postgresql.org/docs/current/warm-standby.html#SYNCHRONOUS-REPLICATION
- PostgreSQL 18: shared_buffers: https://www.postgresql.org/docs/current/runtime-config-resource.html
- pgBackRest (S3 and SFTP repositories): https://pgbackrest.org/
- CISA: Data Backup Options (3-2-1): https://www.cisa.gov/sites/default/files/publications/data_backup_options.pdf
- Garage: project page (multi-datacenter, 200 ms / 50 Mbps): https://garagehq.deuxfleurs.fr/
- Garage: configuration (replication_factor, quorums): https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
- Garage: S3 compatibility (no Object Lock, no versioning): https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/
- Npgsql: failover and load balancing (multi-host): https://www.npgsql.org/doc/failover-and-load-balancing.html
- Mermaid 12.2.0 (MIT), vendored on this site: https://www.npmjs.com/package/mermaid