Betriebshandbuch Hetzner · netcup · STRATO · Stand 02.11.2026

nextxch hochverfügbar über mehrere Provider

Fällt ein ganzer Provider aus, soll Mail trotzdem weiterlaufen: ohne teures Anycast, ohne Enterprise-Load-Balancer, deploybar aus Git. Diese Anleitung zeigt drei Ausbaustufen mit günstigen Servern bei Hetzner, netcup und STRATO, erklärt was bei einem Ausfall Sekunde für Sekunde passiert, und rechnet Ressourcen und Kosten aus. Jede Aussage über Provider ist mit Quelle und Prüfdatum belegt, jede Aussage über nextxch mit der Quelldatei im Code.

01 · Was nextxch für Hochverfügbarkeit mitbringt (im Code geprüft)

nextxch ist Active-Active-fähig: Jeder Knoten nimmt SMTP, IMAP, JMAP, ActiveSync und HTTPS an, es gibt keinen passiven Knoten und keine Sticky Sessions. Den gemeinsamen Zustand hält PostgreSQL, die E-Mails selbst liegen als SHA-256-adressierte .eml-Objekte im Blob-Speicher.

FähigkeitWieQuelle im Code
Cluster-Backplane ohne RedisPostgreSQL LISTEN/NOTIFY (Kanal nextxch_cluster_events) für Ereignisse wie neue Mail, Flags, Expunge, Queue; UNLOGGED-Tabellen cluster_ephemeral und cluster_nodes; Heartbeat alle 15 s, Knoten nach 60 s ohne Heartbeat entferntsrc/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs
Leader-Aufgabenpg_try_advisory_lock: genau ein Knoten erneuert ACME-Zertifikate, sendet den Lizenz-Heartbeat, synchronisiert LDAP, verschickt Kalender-ErinnerungenAcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs
SMTP-Queue über mehrere KnotenFOR UPDATE SKIP LOCKED: jeder Knoten holt sich andere Queue-Einträge, keine Doppelzustellungsrc/Nextxch.Storage/Postgres/PostgresMailStore.cs
Cluster-fähige DatenbankenPostgreSQL (Standard für Cluster) und SQL Server (sp_getapplock); SQLite nur Einzelknotensrc/Nextxch.Storage/StorageServiceCollectionExtensions.cs
Blob-SpeicherFileSystem, S3 (eigener SigV4-Client mit Content-MD5) oder Hybrid (lokaler NVMe-Cache + S3). Object-Lock-Modi Normal, Governance, Compliance und Legal Holdsrc/Nextxch.Storage/Blobs/S3BlobStore.cs, HybridBlobStore.cs
Zertifikate & DKIMACME-Zertifikate und DKIM-Schlüssel liegen in der Datenbank; HTTP-01-Challenges werden über den Cluster geteilt, jeder Knoten kann sie beantworten; der Leaf-Key wird bei Erneuerung beibehalten (DANE 3 1 1 bleibt gültig)src/Nextxch.Security/Tls/AcmeCertificateManager.cs
Health-Endpunkte/health/live (ohne DB), /health/ready (DB erreichbar, sonst 503), /health (Übersicht inkl. Cluster-Peers)src/Nextxch.Server/Program.cs
Echte Client-IPPROXY Protocol v1/v2 für SMTP, IMAP, ManageSieve (Cluster:EnableProxyProtocol) und X-Forwarded-For für HTTP aus TrustedProxyCidrssrc/Nextxch.Core/Abstractions/IClusterCoordinator.cs
Deploy-Artefaktedocker-compose.yml, Kubernetes-Manifeste (3 Replicas, HPA 2 bis 10, PDB, Probes), Helm-Chart, deb/rpm, Windows-MSIdeploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/
LizenzGezählt werden aktive Postfächer, nicht Knoten oder Pods; der Lizenzstatus wird über cluster_kv geteiltsrc/Nextxch.Server/Services/ClusterLicenseService.cs, Preise

Ports pro Knoten: 25, 465, 587 (SMTP), 143, 993 (IMAP), 4190 (ManageSieve), 80/443 (HTTPS, ACME, Webmail, ActiveSync, JMAP). POP3 110/995 ist standardmäßig aus (Pop3:Enabled=false in Pop3Settings.cs).

Die eine harte Bedingung
Alle Knoten brauchen in jedem Moment genau eine beschreibbare PostgreSQL-Primary und einen gemeinsamen Blob-Speicher. Ohne erreichbare Datenbank meldet /health/ready 503 und der Knoten kann keine Empfänger prüfen. Hochverfügbarkeit über Provider heißt deshalb: PostgreSQL und Blob-Speicher selbst über die Standorte replizieren und automatisch umschalten. Genau das bauen die drei Setups.

02 · Mail-spezifische Regeln, die Hochverfügbarkeit leicht machen

Eingehend: MX ist eingebautes Failover

Absender sortieren MX-Einträge nach Priorität und müssen jede Adresse versuchen; bei gleicher Priorität müssen sie zufällig verteilen. [1] Ist ein Ziel nicht erreichbar, wird nach mindestens 30 Minuten erneut versucht, aufgegeben wird frühestens nach 4 bis 5 Tagen. [1]

Ausgehend: SPF und PTR pro IP

SPF muss jede ausgehende IP aller Standorte enthalten; ip4/ip6 zählen nicht zum Limit von 10 DNS-Abfragen. [2] Gmail verlangt gültiges Forward- und Reverse-DNS; der PTR wird bei jedem Provider einzeln gesetzt. [8][14][27][31]

DKIM, MTA-STS, DANE

DKIM-Schlüssel liegen in der Cluster-Datenbank, ein DNS-Satz gilt für alle Knoten; Selektoren erlauben mehrere Schlüssel parallel. [3] Die MTA-STS-Policy muss jeden MX-Host nennen; Wildcards decken nur das linke Label ab. [4] DANE braucht einen TLSA-Eintrag je MX-Host, empfohlen ist 3 1 1, und eine DNSSEC-signierte Zone. [5]

Clients: IMAP, Submission, HTTPS

Mail-Clients kennen kein MX-Failover. Sie verbinden sich mit einem Namen (hier mail.example.com), dessen A-Records ein Health-Check-Job pflegt. IDLE-Verbindungen werden spätestens alle 29 Minuten neu aufgebaut und landen dann auf einem gesunden Knoten. [7][6]

DNS-Zone für zwei Standorte (Muster)
; 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 und PTR
Der Server-Hostname (mail.example.com, für Clients, Autodiscover und Zertifikat) gilt clusterweit. Den EHLO/HELO-Namen setzt jeder Knoten selbst: Smtp:HeloHostname in der Konfiguration oder in /admin/ unter „Hostname, IPv6 & Ports“ (EHLO-Name pro Knoten, mit PTR-Prüfung je Knoten). Er gilt für den 220-Banner, die EHLO-Antwort und ausgehendes EHLO. PTR jeder IP auf ihren festen Namen mx1/mx2 setzen; der DNS-Advisor zeigt je Knoten einen HELO-SPF-Eintrag. RFC 5321 Abschnitt 4.1.4 verbietet ohnehin, eine Nachricht nur wegen eines unpassenden EHLO-Namens abzulehnen. [1][8]
EHLO-Name pro Knoten
# 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 mit allen MX-Hosts (liefert nextxch pro Domain aus)
# 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 · Wie Clients umgeleitet werden: Optionen im Vergleich

Failover-IPs der Provider helfen nur innerhalb eines Providers. Für den Ausfall eines ganzen Providers bleibt DNS das einfache, günstige Mittel.

OptionReichweiteKostenBewertung
Mehrere MX gleicher Prioritätproviderübergreifend0 €Pflicht für eingehende Mail, genormt [1]
Eigener Health-Check-Job + DNS-API (Cloudflare, Hetzner, deSEC, netcup)providerübergreifend0 €Empfohlen. Cloudflare: TTL ab 60 s (Enterprise 30 s), Auto = 300 s. Hetzner DNS: kostenlos, TTL ab 60 s, API in der Hetzner Console. deSEC: Mindest-TTL pro Domain, Beispiel 3600 s, niedriger nur auf Antrag. netcup: CCP-DNS-API vorhanden, Mindest-TTL nicht dokumentiert. [38][39][21][40][29]
Cloudflare Load Balancing (nur DNS)providerübergreifendab 5 USD/MonatHealth Checks (u. a. TCP, SMTP) alle 15 s außerhalb von Enterprise; für A/AAAA/CNAME, MX/SRV nur Enterprise. Ersetzt den eigenen Job für mail.example.com. [33][34][35][36]
Cloudflare SpectrumproviderübergreifendEnterpriseBeliebige TCP-Ports (25, 465, 587, 993) nur als Enterprise-Add-on; Pro/Business nur SSH, Minecraft, RDP. [37]
Hetzner Floating IP (Cloud)ein Projekt, eine Network ZoneIPv4 3,00 €, IPv6 1,00 €/MonatNur innerhalb Hetzner [9]
Hetzner Failover-IP (Robot, dedizierte Server)Hetzner-Rechenzentren4,20 €/Monat + 4,90 € EinrichtungUmschalten per Robot-API in 90 bis 110 s, nur innerhalb Hetzner [11]
netcup Failover-IPgleicher netcup-Standort5,00 €/Monat inkl. MwSt.Umrouten im SCP jederzeit kostenlos, nur innerhalb netcup [24][25]
Akamai/Linode IP-Sharingein Rechenzentrum, zwei Linodesab 5 USD (Nanode)BGP-basiert (lelastic), nur im selben Rechenzentrum; NodeBalancer Basic 10 USD, TCP/HTTP [41][42]
Anycast / eigenes BGPweltweithochEigene ASN (RIPE-Vertrag über Mitgliedschaft oder sponsernden LIR) plus eigener Adressraum (bei Vultr mindestens /24 IPv4 bzw. /48 IPv6). Für die meisten nextxch-Installationen unverhältnismäßig. [43][44]
Warum ein Zeuge nötig ist
Mit zwei Standorten kann keiner unterscheiden, ob der andere ausgefallen oder nur die Verbindung unterbrochen ist. Würden beide sich zur Primary erklären, entstünden zwei auseinanderlaufende Datenbanken (Split Brain). etcd entscheidet per Mehrheit: von drei Mitgliedern genügen zwei. Deshalb: drei Stimmen an drei Providern, wobei die dritte ein kleiner Zeuge sein darf. [48][53]

04 · Setup S: zwei Standorte plus Zeuge

Für kleine und mittlere Unternehmen. Standort A (z. B. Hetzner Cloud) und Standort B (z. B. netcup VPS) betreiben je nextxch, PostgreSQL unter Patroni, ein etcd-Mitglied und eine Garage-Zone. Ein kleiner Zeuge C bei einem dritten Provider (z. B. STRATO) hält das dritte etcd-Mitglied, die dritte Garage-Kopie und den DNS-Failover-Prüfer. Alle drei sind per WireGuard vermascht.

flowchart TB
  U["Nutzer: IMAP, SMTP-Submission, HTTPS"] -->|"mail.example.com TTL 60"| DNS["DNS mit Health-Check-Job"]
  S["Fremde Mailserver"] -->|"MX 10 mx1 / MX 10 mx2"| A1
  S --> B1
  DNS --> A1
  DNS --> B1
  subgraph A["Standort A: Hetzner"]
    A1["nextxch"] --> AP[("PostgreSQL Primary")]
    A1 --> AG["Garage Zone A"]
    AE["etcd"]
  end
  subgraph B["Standort B: netcup"]
    B1["nextxch"] --> BP[("PostgreSQL Replikat")]
    B1 --> BG["Garage Zone B"]
    BE["etcd"]
  end
  subgraph C["Zeuge C: STRATO"]
    CE["etcd"]
    CG["Garage Zone C"]
    CF["Failover-Prüfer"]
  end
  B1 -.->|"WireGuard"| AP
  AP ==>|"synchrone Replikation"| BP
  AG <-.-> BG
  BG <-.-> CG
  AE <-.-> BE
  BE <-.-> CE
Architektur Setup S

Warum Garage als Blob-Speicher?

Ein S3-Bucket bei nur einem Provider wäre beim Ausfall dieses Providers weg, und nextxch kann heute nur in ein S3-Ziel schreiben. Garage ist ein S3-kompatibler Speicher (AGPL-3.0), ausgelegt für mehrere Rechenzentren über das Internet (Anforderung laut Projekt: höchstens 200 ms Latenz, mindestens 50 Mbit/s). Mit replication_factor = 3 liegt je eine Kopie pro Zone; Lesen und Schreiben laufen weiter, solange nur eine Zone ausfällt (Schreib-Quorum 2). Mit nur zwei Zonen wäre bei Ausfall einer Zone nur noch Lesen möglich, deshalb trägt auch der Zeuge eine volle Kopie. [60][61]

Nicht getestet und ohne Object Lock
Garage unterstützt kein S3 Object Lock und keine Versionierung. Domains mit WORM-Aufbewahrung (Governance/Compliance) schreiben bei nextxch Retention-Header und brauchen deshalb ein S3 mit Object Lock; siehe Kapitel 09 für die GoBD-Kopie. Den Betrieb von nextxch auf Garage haben wir für diese Anleitung nicht mit einem Lasttest geprüft: vor dem Produktivbetrieb mit Testdaten verifizieren. [62]

Schritt für Schritt

  1. Server bestellen: A = Hetzner Cloud (Port 25/465 sind dort standardmäßig gesperrt; Freigabe nach einem Monat und bezahlter erster Rechnung beantragen, 587 ist offen), B = netcup VPS (Firewall-Policy „netcup Mail block“ im SCP entfernen), C = STRATO VPS als Zeuge (Port 25 wird für den Zeugen nicht gebraucht). [13][26]
  2. PTR setzen: IP A auf mx1.example.com (Hetzner Console), IP B auf mx2.example.com (netcup SCP). [14][27]
  3. Grundinstallation per cloud-init (Debian 13): Deploy-Benutzer, Docker, WireGuard, keine Passwort-Anmeldung.
  4. WireGuard-Mesh 10.88.0.0/24 zwischen A, B, C; UDP 51820 nur für die Partner-IPs öffnen. [47]
  5. etcd auf allen drei (Election-Timeout mindestens 10 × RTT), dann Patroni + PostgreSQL 18 auf A und B. [50][53]
  6. Garage auf allen drei, Layout mit drei Zonen anwenden, Bucket und Schlüssel anlegen. [61]
  7. nextxch per Docker Compose auf A und B starten (Cluster:Enabled=true, Multi-Host-Connection-String, S3 auf das lokale Garage). In /admin/ den Hostnamen mail.example.com setzen und das ACME-Zertifikat für mail, mx1, mx2 ausstellen.
  8. DNS wie in Kapitel 02, MTA-STS-MX-Liste in /admin/ prüfen, DNS-Failover in /admin/ unter Domains aktivieren (nur der Cluster-Leader arbeitet) oder alternativ das Skript unter etcd-Lock.
  9. Backups wie in Kapitel 09 einrichten und eine Wiederherstellung testen. Dann einen Ausfall üben: Standort A hart abschalten und Kapitel 08 nachverfolgen.
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 je Standort
# 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:

Der Connection-String nennt beide PostgreSQL-Hosts mit Target Session Attributes=primary. nextxch erzeugt seine Verbindungen über NpgsqlDataSource.Create (PostgresMailStore.cs); Npgsql baut bei mehreren Hosts automatisch eine Multi-Host-Datenquelle und verbindet nur mit der aktuellen Primary, der Status wird alle Host Recheck Seconds neu geprüft (Standard 10 s). Nach einem Patroni-Failover verbinden sich die Knoten so ohne Konfigurationsänderung neu; die LISTEN-Verbindung des Clusters versucht es alle 2 s erneut (PostgresClusterCoordinator.cs). [63]

Automatischer DNS-Failover für mail.example.com

nextxch bringt einen DNS-Failover-Agenten mit (opt-in, standardmäßig aus; /admin/ unter Domains, Karte „DNS-Failover“). Jeder Knoten prüft alle 30 s den SMTP-Banner auf Port 25 und HTTPS /health jedes Knotens über dessen öffentliche IP. Nur der Cluster-Leader handelt: er hält einen PostgreSQL-Advisory-Lock, fasst die Sicht aller Knoten per Mehrheit zusammen und schreibt die A/AAAA-Einträge der konfigurierten Namen über die API von Hetzner DNS (Cloud-API) oder Cloudflare. Hysterese: nach N Fehlrunden fliegt ein Knoten aus dem DNS, nach M erfolgreichen Runden kommt er zurück; der letzte Knoten wird nie entfernt. Jede Änderung steht im Ereignisprotokoll, die Alarmregel „DNS failover: node taken out“ benachrichtigt über die Ops-Alarme. Das API-Token wird mit dem Server-Schlüsselbund verschlüsselt und nie wieder angezeigt.

Eingebauter 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.
Warum ein abgeschnittener Knoten DNS nicht ändert
Den Leader-Lock gibt es nur über eine Verbindung zur PostgreSQL-Primary. Mit Target Session Attributes=primary im Connection-String (Kapitel 04 oben) erreicht eine abgeschnittene Seite keine Primary, bekommt den Lock nicht und bleibt passiv. Die Mehrheitsentscheidung über alle Beobachter verhindert, dass ein einzelner Knoten mit gestörter Sicht einen gesunden Knoten entfernt.

Alternative: Skript unter etcd-Lock

Für DNS-Provider ohne eingebauten Anschluss (z. B. deSEC) oder wenn etcd die Entscheidung treffen soll, bleibt das Skript eine gleichwertige Alternative. Agent und Skript nicht gleichzeitig für dieselben Namen einsetzen. Das Skript prüft alle 20 s /health/ready jedes Knotens über dessen öffentliche IP und gleicht die A-Records über die Cloudflare-API ab (PATCH/POST/DELETE auf dns_records). Es läuft auf jedem Standort unter etcdctl lock: nur ein Exemplar arbeitet, und nur auf der Seite mit etcd-Mehrheit. Ein abgeschnittener Standort kann den DNS daher nicht mehr verändern. Für Hetzner DNS oder deSEC nur die drei curl-Aufrufe tauschen. [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: drei vollwertige Standorte (Proxmox oder einfache VMs)

Wie Setup S, aber an allen drei Standorten läuft nextxch mit PostgreSQL-Replikat. Fällt ein Standort aus, bleiben zwei Datenbanken und zwei nextxch-Knoten übrig, mit synchronous_mode bleibt sogar die synchrone Replikation erhalten. Drei MX-Einträge gleicher Priorität verteilen die eingehende Last.

flowchart TB
  S["Fremde Mailserver"] -->|"MX 10 mx1, mx2, mx3"| N
  U["Nutzer"] -->|"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["Dritter Provider"]
      C1["nextxch"] --- C2[("Patroni + PG")] --- C3["etcd + Garage"]
    end
  end
  N --> BK["Backups: Storage Box + S3 bei anderem Provider"]
Architektur Setup M
Proxmox: kein Cluster über Provider hinweg
Corosync braucht laut Proxmox Latenzen unter 5 ms (LAN-Niveau); über ca. 10 ms und mehr als drei Knoten wird es unwahrscheinlich, dass es funktioniert. Ein einziger Proxmox-VE-Cluster über Hetzner, netcup und STRATO ist deshalb nicht empfohlen. Richtig ist: je Provider ein eigenständiger Proxmox-Host oder -Cluster, die Hochverfügbarkeit entsteht in der Anwendung (Patroni, Garage, nextxch-Cluster). Ein QDevice (corosync-qnetd) ist für Cluster mit zwei Knoten an einem Standort gedacht und unterliegt nicht dem Latenzlimit. Für die gemeinsame Verwaltung mehrerer Cluster gibt es den Proxmox Datacenter Manager (1.0 seit 04.12.2025). [45]

Schritt für Schritt

  1. Drei VMs (oder Proxmox-Gäste) bei drei Providern, gleiche Debian-Version, je PTR auf mx1/mx2/mx3.
  2. Ansible-Inventar mit drei Hosts, ein Playbook für alle (WireGuard, etcd, Patroni, Garage, nextxch).
  3. Patroni-Konfiguration aus Setup S mit drittem Host; synchronous_mode: on, bei drei Datenbanken optional quorum. [54]
  4. Connection-String mit drei Hosts, Garage-Layout mit drei Zonen, drei MX-Einträge, drei IPs im SPF und in der DNS-Failover-Knotenliste.
  5. Proxmox Backup Server optional für VM-Images: als Datastore lokal oder S3 (S3-Backend seit PBS 4.2 offiziell); Netzlaufwerke wie eine Storage Box per CIFS dokumentiert Proxmox nicht als Datastore-Typ. [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 bei drei Providern

Für viele Postfächer und horizontales Skalieren. Wichtig vorab: k3s unterstützt embedded etcd in einem über mehrere Clouds verteilten Cluster ausdrücklich nicht, die Server-Knoten sollen an einem Ort stehen. Deshalb: ein Kubernetes-Cluster pro Provider (je k3s oder Talos), in jedem eine nextxch-Deployment aus dem mitgelieferten Helm-Chart. Die Datenschicht (Patroni + etcd + Garage) liegt wie in Setup M auf eigenen VMs über WireGuard und wird von allen drei Clustern genutzt. [49][48]

flowchart TB
  DNS["DNS: MX je Provider-LB, mail.example.com mit 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["Datenschicht auf VMs, WireGuard"]
    PG[("Patroni: Primary + 2 Replikate")]
    GA["Garage: 3 Zonen"]
    ET["etcd: 3 Mitglieder"]
  end
  P1 --> PG
  P2 --> PG
  P3 --> PG
  P1 --> GA
  P2 --> GA
  P3 --> GA
Architektur Setup L

Warum nicht CloudNativePG über Cluster?

CloudNativePG kann Replica-Cluster in anderen Kubernetes-Clustern betreiben, das Hochstufen und Zurückstufen zwischen Clustern erfolgt aber deklarativ (Feld primary, promotionToken), also nicht automatisch. Für automatischen Provider-Failover ist Patroni mit etcd über drei Standorte die direktere Lösung. [52]

Ein Cluster über alle Provider?

Talos bietet mit KubeSpan ein WireGuard-Vollmaschennetz über Standorte, k3s ein flannel-Backend wireguard-native. Die Control Plane (etcd) reagiert aber empfindlich auf WAN-Latenz. Mehrere kleine Cluster sind einfacher zu betreiben und fallen unabhängig aus. [51][49][50]

Schritt für Schritt

  1. Datenschicht wie Setup M aufbauen (drei VMs mit Patroni, etcd, Garage).
  2. Je Provider einen k3s- oder Talos-Cluster; Netzverbindung zur Datenschicht per WireGuard (oder KubeSpan).
  3. Eingehende Ports: Service vom Typ LoadBalancer mit externalTrafficPolicy: Local (so im Repo), damit die Absender-IP für SPF/DMARC erhalten bleibt; bei einem Load Balancer mit PROXY Protocol stattdessen Cluster__EnableProxyProtocol=true. Der Hetzner Cloud Load Balancer unterstützt PROXY Protocol und beliebige TCP-Dienste. [19]
  4. Ausgehend Port 25: Pod-Egress über eine feste IP mit passendem PTR und SPF-Eintrag (Egress-Gateway, wie in deploy/kubernetes/README.md beschrieben).
  5. Helm-Werte pro Provider, Rollout per Pipeline nacheinander (Kapitel 07). HPA (2 bis 10 Pods bei 70 % CPU) und PodDisruptionBudget liefert das Repo mit.
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 · Aus Git deployen und aktualisieren

Alles Wichtige liegt als Datei im Repository: Compose-Datei bzw. Helm-Werte, Ansible-Inventar, DNS-Failover-Skript. Die Pipeline rollt immer nur einen Standort aus und wartet, bis /health/ready wieder „ready“ meldet, bevor der nächste folgt. Geheimnisse (Lizenz, Passwörter, API-Tokens, SSH-Schlüssel) liegen ausschließlich in den Secret-Speichern von GitHub/GitLab bzw. in der .env auf dem Server, nie im Repository.

flowchart TB
  G["git push auf main"] --> V["Validieren: compose config / helm lint"]
  V --> A["Standort A ausrollen"]
  A --> HA{"/health/ready = ready?"}
  HA -->|"ja"| B["Standort B ausrollen"]
  HA -->|"nein"| X["Abbruch, B bleibt alt"]
  B --> HB{"/health/ready = ready?"}
  HB -->|"ja"| C["Standort C ausrollen"]
  HB -->|"nein"| X
  C --> OK["Fertig"]
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
Der Failover-Job gehört nicht in die CI
Geplante CI-Läufe sind weder sekundengenau noch garantiert. Der DNS-Failover läuft deshalb auf den Standorten selbst: als eingebauter Agent in nextxch oder als systemd-Dienst mit dem Skript (Kapitel 04), den die Pipeline nur ausrollt.
Auch die Mail-Konfiguration aus Git
Domains, Postfächer, Verteiler, Regeln, SMTP und die übrigen Einstellungen liegen ebenfalls als YAML im Repository: nextxch-cli config plan kommentiert den Pull Request mit dem Plan, config apply --prune --yes wendet ihn nach dem Merge an. Anleitung mit fertigen GitHub-Actions- und GitLab-CI-Dateien: Konfiguration als Datei (GitOps).

08 · Ausfall-Szenarien: was genau passiert

Hetzner (Standort A, aktuelle Primary) fällt komplett aus

sequenceDiagram
  participant S as Fremder Mailserver
  participant A as Standort A (Hetzner)
  participant B as Standort B (netcup)
  participant C as Zeuge C
  participant D as DNS
  Note over A: Provider-Ausfall
  S->>A: SMTP an mx1
  A--xS: keine Verbindung
  S->>B: SMTP an mx2 (oder Wiederholung)
  B--xS: ohne Primary scheitert die Sitzung, Absender wiederholt später
  Note over B,C: etcd-Mehrheit B + C, Leader-Key von A läuft ab (ttl 30 s)
  B->>B: Patroni stuft PostgreSQL B zur Primary hoch
  B->>B: nextxch verbindet neu (Target Session Attributes=primary)
  S->>B: erneuter Versuch
  B-->>S: 250 OK (Commit synchron oder asynchron)
  C->>D: Failover-Job entfernt IP A aus mail.example.com
  Note over D: Clients folgen nach Ablauf der TTL
Ablauf beim Ausfall von Standort A
ZeitWas passiertWas Nutzer merken
0 sA ist weg. Eingehende Mail an mx1 scheitert, Absender nehmen mx2 (gleiche Priorität) oder versuchen später erneut. [1]Clients auf A verlieren die Verbindung.
bis ca. 30 sDer Leader-Schlüssel von A in etcd läuft nach ttl = 30 s ab; B und C haben Mehrheit. [53]Knoten B meldet 503 auf /health/ready, Schreibzugriffe warten.
ca. 30 bis 60 sPatroni stuft B zur Primary hoch (loop_wait 10 s). nextxch auf B findet die neue Primary über den Multi-Host-Connection-String (Recheck 5 bis 10 s). [63]B nimmt wieder Mail an.
ca. 60 bis 90 sDNS-Failover (Agent: 3 Fehlrunden × 30 s, Skript: 3 × 20 s) entfernt IP A aus mail.example.com.Neue Verbindungen gehen nach Ablauf der TTL (60 s) zu B.
1 bis 5 minResolver mit eigenen Mindest-TTLs und Clients mit DNS-Cache folgen. Garage arbeitet mit den Zonen B und C weiter (Quorum 2 von 3). [61]Clients verbinden neu; Webmail-Sitzungen bleiben gültig (signierte, zustandslose Sitzungen).

RPO

Mit synchronous_mode bestätigt PostgreSQL einen Commit erst, wenn ihn der zweite Standort hat; ein mit 250 OK angenommenes E-Mail ist dann an zwei Standorten. Preis dafür: jeder Commit wartet mindestens eine Round-Trip-Zeit zwischen den Standorten. Ohne synchronous_mode können die letzten Sekunden vor dem Ausfall fehlen. Mit synchronous_mode_strict: false schaltet Patroni auf asynchron zurück, wenn kein Replikat mehr da ist, damit Mail weiter angenommen wird. [56][54]

RTO

Datenbank etwa 30 bis 60 s (Patroni-Standardwerte), DNS-Umstellung ca. 1 min plus TTL, eingehende Mail ohne Wartezeit über den zweiten MX bzw. per Wiederholung des Absenders. Diese Zeiten sind aus den Standardwerten abgeleitet, nicht gemessen: im eigenen Test (Standort hart abschalten) nachprüfen.

netcup (Standort B, Replikat) fällt aus

Die Primary auf A läuft weiter. Patroni stellt mangels synchronem Partner auf asynchron um (strict = false), der Failover-Job entfernt IP B, eingehende Mail läuft über mx1. Garage arbeitet mit A und C weiter.

Nur die Leitung zwischen A und B ist unterbrochen

Die Seite, die den Zeugen C noch erreicht, behält die etcd-Mehrheit und bleibt bzw. wird Primary. Die andere Seite verliert ihre Mehrheit, Patroni stuft dort eine Primary zurück (failsafe_mode greift nur, wenn sie noch alle Mitglieder erreicht), und der DNS-Failover handelt dort nicht: der eingebaute Agent erreicht keine Primary und bekommt den Leader-Lock nicht, das Skript bekommt den etcd-Lock nicht. So entsteht kein Split Brain. [55]

Failback

sequenceDiagram
  participant A as Standort A
  participant B as Standort B (Primary)
  participant G as Garage
  participant D as DNS-Job
  A->>A: Server wieder online, WireGuard steht
  A->>B: Patroni: pg_rewind, dann Replikat von B
  G->>A: fehlende Objekte werden nachrepliziert
  A->>A: /health/ready = ready
  D->>D: 3 erfolgreiche Prüfungen
  D->>A: IP A wieder in mail.example.com
  Note over A,B: optional geplant: patronictl switchover zurück nach A
Rückkehr von Standort A

Kommt A zurück, bleibt B Primary. Patroni führt A mit pg_rewind (use_pg_rewind: true) als Replikat wieder heran, Garage gleicht die fehlenden Objekte nach, der Failover-Job nimmt IP A wieder auf, sobald /health/ready dort „ready“ meldet. Ein Zurückschalten der Primary ist nicht nötig; wer es möchte, macht es geplant mit patronictl switchover zu einer ruhigen Zeit.

09 · Backup, Wiederherstellung und GoBD

Replikation schützt vor Ausfällen, nicht vor Fehlern: ein versehentlich gelöschtes Postfach ist sofort an allen Standorten gelöscht. Deshalb zusätzlich Backups nach der 3-2-1-Regel: drei Kopien, zwei Medientypen, eine außer Haus. [59]

flowchart LR
  PG[("PostgreSQL Primary oder Replikat")] -->|"pgBackRest, WAL laufend"| R1["Repo 1: Hetzner Storage Box (SFTP)"]
  PG -->|"pgBackRest"| R2["Repo 2: S3 bei anderem Provider"]
  GA["Garage (3 Zonen)"] -->|"rclone copy stündlich"| AR["Object Storage mit Object Lock (GoBD)"]
  GA -->|"rclone copy"| R1
  R1 --> T["Restore-Test quartalsweise"]
  R2 --> T
Backup-Fluss
  • PostgreSQL: pgBackRest mit kontinuierlicher WAL-Archivierung (Point-in-Time-Recovery). Repository 1 auf einer Hetzner Storage Box per SFTP (pgBackRest unterstützt SFTP seit 2.46; die Storage Box bietet SFTP, SCP, rsync, BorgBackup, Restic, Rclone, SMB, WebDAV), Repository 2 auf S3 bei einem anderen Provider. Beide verschlüsselt. [58][15]
  • Blobs: Die .eml-Objekte sind nach SHA-256 benannt und werden nie verändert. rclone copy (kein sync, löscht also nie) reicht als stündliche Kopie.
  • GoBD-Archiv: Hetzner Object Storage unterstützt Object Lock in den Modi GOVERNANCE und COMPLIANCE sowie Versionierung; Object Lock muss beim Anlegen des Buckets aktiviert werden. Bucket mit Standard-Aufbewahrung (Compliance) anlegen, dann ist jedes kopierte Objekt automatisch gesperrt. Alternativ nextxch direkt auf diesen Bucket schreiben lassen (S3- oder Hybrid-Backend), dann hängt die Verfügbarkeit des Blob-Speichers aber an Hetzner. In diesem Fall legt nextxch einen fehlenden Bucket beim Start selbst mit Object Lock an, sobald ein WORM-Modus konfiguriert ist (oder Storage:S3:ObjectLockEnabled); „S3-Bucket prüfen“ im GoBD-Archiv zeigt Erreichbarkeit, Versionierung, Object-Lock-Modus und Standard-Aufbewahrung. [18][17]
  • Wiederherstellung testen: mindestens quartalsweise ein pgBackRest-Restore in eine Test-VM und Stichproben aus der Blob-Kopie öffnen.
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-Kopie und 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 · Skalierung: Nutzer, Speicher, Pods

flowchart LR
  S1["Start: 2 Standorte + Zeuge"] --> S2["Mehr Postfächer: nextxch-Knoten pro Standort ergänzen"]
  S2 --> S3["DB wächst: PostgreSQL-VMs vertikal (RAM, NVMe)"]
  S3 --> S4["Mehr Speicher: Garage-Knoten pro Zone ergänzen"]
  S4 --> S5["Viele Postfächer: Kubernetes je Provider, HPA"]
Skalierungspfad
BausteinSkaliertHinweis
nextxch-Knoten / PodshorizontalZustandslos; mehr Knoten pro Standort oder mehr Pods (HPA). Keine Sticky Sessions nötig.
PostgreSQL-PrimaryvertikalAlle Schreibzugriffe gehen an eine Primary. Mehr RAM und schnellere Disks (IOPS) statt mehr Knoten; shared_buffers als Startwert 25 % des RAM. Lesereplikate nutzt nextxch nicht. [57]
Blob-SpeicherhorizontalWeitere Garage-Knoten pro Zone mit Kapazität (garage layout assign -c), Daten werden umverteilt. Deduplizierung über SHA-256 und Zstd-Kompression senken den Bedarf.
SMTPüber DNSMehr MX-Einträge gleicher Priorität verteilen die Last; die Ausgangs-Queue verteilt sich über SKIP LOCKED auf alle Knoten.
IMAPüber DNS / LBLanglebige IDLE-Verbindungen bleiben auf ihrem Knoten; neue Mail von anderen Knoten kommt per LISTEN/NOTIFY an. Nach dem Hinzufügen eines Knotens verteilt sich die Last erst mit Neuverbindungen. [7]
HTTPS (Webmail, ActiveSync, JMAP)über DNS / LBZustandslose HMAC-Sitzungen; jeder Request darf jeden Knoten treffen.

11 · Kosten: wo sparen, wo nicht

Sparen

  • Shared-vCPU-Tarife für nextxch und Zeugen; dedizierte Kerne erst, wenn die CPU-Last der Datenbank es zeigt.
  • 12-Monats-Laufzeit bei netcup (13 % unter dem Monatspreis laut Preisseite). [22]
  • DNS-Failover mit dem eingebauten Agenten (oder eigenem Skript) statt kostenpflichtigem Load Balancer.
  • Zstd-Kompression (Storage:Compression) und SHA-256-Deduplizierung für Blobs.

Nicht sparen

  • RAM der PostgreSQL-Knoten: alles, was nicht im Cache liegt, kostet Disk-IO bei jeder Ordneransicht.
  • Disk-IOPS für PostgreSQL (NVMe), WAL-Schreiben bestimmt die Latenz jeder Zustellung.
  • Den dritten Standort: ohne ihn gibt es keinen sicheren automatischen Failover.
  • Getestete Backups bei einem unabhängigen Ziel.

Hinterlegte Preise (netto pro Monat, Stand 02.11.2026). netcup und STRATO weisen Bruttopreise inkl. 19 % MwSt. aus, hier durch 1,19 geteilt. Bei Hetzner sind 0,50 € für die Primary IPv4 addiert. Hetzner zeigte beim Abruf alle Cloud-Tarife als „currently unavailable“; ob das Lagerbestand oder eine Anzeige war, ließ sich nicht klären. Den Preis für Hetzner Volumes konnten wir auf den offiziellen Seiten nicht ablesen, er fehlt deshalb im Rechner. Preise ändern sich: vor der Bestellung beim Anbieter prüfen. [12][10][22][23][30][15][16]

AnbieterTarifvCPURAMDisk€ netto / Monat
Hetzner CloudCX2324 GB40 GB6,49 €
Hetzner CloudCX3348 GB80 GB9,49 €
Hetzner CloudCX43816 GB160 GB16,99 €
Hetzner CloudCX531632 GB320 GB30,49 €
netcup VPSVPS 500 G12.524 GB64 GB6,94 €
netcup VPSVPS 1000 G12.548 GB128 GB12,18 €
netcup VPSVPS 2000 G12.5816 GB256 GB22,62 €
netcup VPSVPS 4000 G12.51232 GB512 GB38,12 €
netcup VPSVPS 8000 G12.51664 GB1024 GB56,39 €
netcup Root ServerRS 1000 G12.548 GB128 GB18,26 €
netcup Root ServerRS 2000 G12.5816 GB256 GB34,20 €
netcup Root ServerRS 4000 G12.51232 GB512 GB65,40 €
netcup Root ServerRS 8000 G12.51664 GB1024 GB124,72 €
netcup Root ServerRS 12000 G12.52096 GB1536 GB182,26 €
netcup Root ServerRS 16000 G12.524128 GB2048 GB243,24 €
STRATO VPSVPS S12 GB60 GB3,36 €
STRATO VPSVPS M24 GB120 GB8,40 €
STRATO VPSVPS L48 GB240 GB16,81 €
STRATO VPSVPS XL816 GB480 GB32,77 €
STRATO VPSVPS XXL1224 GB720 GB47,06 €
Hetzner Storage BoxBX11--1 TB3,20 €
Hetzner Storage BoxBX21--5 TB10,90 €
Hetzner Storage BoxBX31--10 TB20,80 €
Hetzner Storage BoxBX41--20 TB40,60 €
Hetzner Object StorageGrundpreis inkl. 1 TB Speicher + 1 TB Egress--1 TB6,49 €

12 · Rechner: Ressourcen und Kosten

Läuft vollständig im Browser, ohne externe Dienste. Die Rechenannahmen stehen offen unter dem Ergebnis.

Rechenannahmen (nicht gemessen, bitte mit eigenen Werten prüfen)
  • Mail-Volumen = Postfächer × Ø Größe × (1 + Wachstum)^Jahre. Ohne Abzug für Deduplizierung oder Kompression (konservativ).
  • PostgreSQL-Größe = 3 % des Mail-Volumens (Metadaten, Flags, Volltextindex), mindestens 2 GB. Den echten Wert liefert SELECT pg_database_size('nextxch').
  • RAM für PostgreSQL = 25 % der DB-Größe, mindestens 2 GB, aufgerundet auf 2/4/8/16/32/64/96/128 GB. Disk für PostgreSQL = 2 × DB-Größe (Daten, WAL, Reserve).
  • nextxch-Prozess: 1 GB + 1 GB je angefangene 1.000 Postfächer (das Kubernetes-Limit im Repo ist 1 GiB pro Pod). etcd + Garage + OS: 1 GB. Zeuge: 2 GB RAM. Betriebssystem: 20 GB Disk.
  • Garage replication_factor 3: jede Zone (Standort bzw. Zeuge) speichert das volle Mail-Volumen.
  • Setup L: pro Provider mindestens 2 Pods, 1 Pod je 1.500 Postfächer, Worker mit 8 GB; die Datenschicht-VM trägt PostgreSQL, etcd und die Garage-Zone.
  • Backup-Bedarf = 3 × DB-Größe (2 Vollsicherungen + WAL) + Mail-Volumen; Ziel: kleinste passende Hetzner Storage Box.
  • Lizenzpreise aus dem aktuellen Preiskatalog dieser Website (Staffel gilt für alle Postfächer, Jahrespreis / 12). Cloudflare wird in USD angezeigt und nicht in die Euro-Summe eingerechnet.

13 · Einen weiteren Provider hinzufügen (Beispiel STRATO)

  1. Vor der Bestellung klären: PTR setzbar? STRATO: Reverse DNS für VPS im Kunden-Login unter Domains, ein A/AAAA-Eintrag muss vorher existieren, Übernahme bis zu 24 h. Port 25: für Root-Server dokumentiert STRATO ausgehend gesperrt mit Freigabe über den Support, für VPS ist keine Regel dokumentiert. Eine Failover-IP bietet STRATO nicht an. [31][32][30]
  2. Neuen Host ins Ansible-Inventar mit eigener WireGuard-IP und Garage-Zone, Peers auf allen Standorten ergänzen, Playbook ausführen.
  3. etcd: Mitglied mit etcdctl member add hinzufügen und mit ETCD_INITIAL_CLUSTER_STATE=existing starten. Bei vier Datenbank-Standorten auf eine ungerade Zahl von etcd-Mitgliedern achten. [48]
  4. Patroni startet als Replikat und repliziert automatisch. Garage: garage layout assign mit neuer Zone, garage layout apply.
  5. DNS: IP in SPF, neuen MX gleicher Priorität, Knoten in die DNS-Failover-Liste (Agent oder Skript), TLSA-Eintrag für den neuen MX-Namen, EHLO-Name und PTR des neuen Knotens, Zertifikat um den Namen erweitern. Die MTA-STS-Policy nimmt den neuen MX automatisch auf (automatische MX-Liste); danach den neuen _mta-sts-TXT-Wert aus dem DNS-Advisor veröffentlichen.
  6. Pipeline-Matrix um den Standort erweitern, Ausfall des neuen Standorts einmal testen.

14 · Grenzen und Lücken (ehrlich)

Beim Erstellen dieser Anleitung im Code gefunden. Vier Lücken sind inzwischen geschlossen: MTA-STS mit mehreren MX-Namen, EHLO-Name pro Knoten, automatisch angelegter Object-Lock-Bucket und der eingebaute DNS-Failover-Agent. Die übrigen umgeht die Anleitung mit den genannten Mitteln.

LückeAuswirkungUmgehung
DNS-Failover-Agent (geschlossen)Eingebaut, opt-in: Health-Checks von jedem Knoten, nur der Cluster-Leader ändert A/AAAA über Hetzner DNS oder Cloudflare.Weitere DNS-Provider (z. B. deSEC): Skript unter etcd-Lock (Kapitel 04).
MTA-STS mit mehreren MX (geschlossen)Die Policy pro Domain nennt alle MX-Namen (DNS-MX, Server- und Knotennamen oder eigene Liste mit Wildcards); die Policy-ID folgt der Liste.Nach jeder MX-Änderung den _mta-sts-TXT-Wert aus dem DNS-Advisor übernehmen.
EHLO-Name pro Knoten (geschlossen)Smtp:HeloHostname oder Einstellung pro Knoten in /admin/, mit PTR-Prüfung je Knoten; der Server-Hostname bleibt clusterweit.-
Nur ein S3-ZielKein doppeltes Schreiben in zwei S3-Speicher bei verschiedenen Providern; die Object-Lock-Funktionen setzen ein S3 mit Object Lock voraus.Garage über drei Standorte plus rclone-Kopie in einen Object-Lock-Bucket.
Object-Lock-Bucket (geschlossen)Ist ein WORM-Modus konfiguriert, legt der Server einen fehlenden Bucket beim Start mit Object Lock an. Ein vorhandener Bucket ohne Object Lock wird als Warnung und Alarm gemeldet, denn Object Lock lässt sich nur beim Anlegen aktivieren. [18]Prüfung jederzeit über „S3-Bucket prüfen“ im GoBD-Archiv.
Kein eingebautes BackupEs gibt keinen Backup-Dienst für Datenbank und Blobs im Server.pgBackRest und rclone (Kapitel 09).
Keine LesereplikateAlle Abfragen gehen an die Primary; Lesereplikate stehen im Entwicklungsplan nur als „auf Nachfrage“.PostgreSQL-Primary vertikal skalieren.
Kein gemessener Sizing-BenchmarkDie RAM- und DB-Größen im Rechner sind Annahmen.Mit eigenen Messwerten überschreiben.

Häufige Fragen

Kann nextxch über mehrere Provider gleichzeitig aktiv laufen?

Ja. Mit Cluster:Enabled=true und PostgreSQL teilen sich beliebig viele nextxch-Knoten den Zustand über LISTEN/NOTIFY, UNLOGGED-Tabellen und Advisory Locks. Alle Knoten müssen dieselbe PostgreSQL-Primary und denselben S3-Blob-Speicher erreichen; über Provider hinweg geschieht das über ein WireGuard-Netz.

Brauche ich einen Load Balancer oder Anycast?

Nein. Für eingehende Mail genügen mehrere MX-Einträge mit gleicher Priorität (RFC 5321 Abschnitt 5.1). Für IMAP, Submission und HTTPS reicht ein Name mit kurzer TTL, dessen A-Records ein Health-Check-Job pflegt. Anycast/BGP ist möglich, aber aufwendig.

Wie viele Standorte brauche ich mindestens?

Drei Stimmen: zwei vollwertige Standorte plus ein kleiner Zeuge (etcd, Garage-Zone, Failover-Prüfer) oder drei vollwertige Standorte. Mit nur zwei Standorten kann keine Seite sicher entscheiden, ob die andere ausgefallen oder nur die Leitung unterbrochen ist (Split Brain).

Gehen bei einem Provider-Ausfall E-Mails verloren?

Absender wiederholen Zustellversuche mindestens 4 bis 5 Tage (RFC 5321 Abschnitt 4.5.4.1). Mit Patroni synchronous_mode und Garage replication_factor 3 ist eine mit 250 OK angenommene Nachricht bereits an einem zweiten Standort gespeichert.

15 · Quellen

Alle externen Angaben wurden am 02.11.2026 bei den folgenden offiziellen Quellen geprüft. Nicht verifizierbare Punkte sind im Text als solche markiert.

  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