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ähigkeit | Wie | Quelle im Code |
|---|---|---|
| Cluster-Backplane ohne Redis | PostgreSQL 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 entfernt | src/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs |
| Leader-Aufgaben | pg_try_advisory_lock: genau ein Knoten erneuert ACME-Zertifikate, sendet den Lizenz-Heartbeat, synchronisiert LDAP, verschickt Kalender-Erinnerungen | AcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs |
| SMTP-Queue über mehrere Knoten | FOR UPDATE SKIP LOCKED: jeder Knoten holt sich andere Queue-Einträge, keine Doppelzustellung | src/Nextxch.Storage/Postgres/PostgresMailStore.cs |
| Cluster-fähige Datenbanken | PostgreSQL (Standard für Cluster) und SQL Server (sp_getapplock); SQLite nur Einzelknoten | src/Nextxch.Storage/StorageServiceCollectionExtensions.cs |
| Blob-Speicher | FileSystem, S3 (eigener SigV4-Client mit Content-MD5) oder Hybrid (lokaler NVMe-Cache + S3). Object-Lock-Modi Normal, Governance, Compliance und Legal Hold | src/Nextxch.Storage/Blobs/S3BlobStore.cs, HybridBlobStore.cs |
| Zertifikate & DKIM | ACME-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-IP | PROXY Protocol v1/v2 für SMTP, IMAP, ManageSieve (Cluster:EnableProxyProtocol) und X-Forwarded-For für HTTP aus TrustedProxyCidrs | src/Nextxch.Core/Abstractions/IClusterCoordinator.cs |
| Deploy-Artefakte | docker-compose.yml, Kubernetes-Manifeste (3 Replicas, HPA 2 bis 10, PDB, Probes), Helm-Chart, deb/rpm, Windows-MSI | deploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/ |
| Lizenz | Gezählt werden aktive Postfächer, nicht Knoten oder Pods; der Lizenzstatus wird über cluster_kv geteilt | src/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).
/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]
; 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 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]# 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 · 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.
| Option | Reichweite | Kosten | Bewertung |
|---|---|---|---|
| Mehrere MX gleicher Priorität | providerübergreifend | 0 € | Pflicht für eingehende Mail, genormt [1] |
| Eigener Health-Check-Job + DNS-API (Cloudflare, Hetzner, deSEC, netcup) | providerübergreifend | 0 € | 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übergreifend | ab 5 USD/Monat | Health 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 Spectrum | providerübergreifend | Enterprise | Beliebige 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 Zone | IPv4 3,00 €, IPv6 1,00 €/Monat | Nur innerhalb Hetzner [9] |
| Hetzner Failover-IP (Robot, dedizierte Server) | Hetzner-Rechenzentren | 4,20 €/Monat + 4,90 € Einrichtung | Umschalten per Robot-API in 90 bis 110 s, nur innerhalb Hetzner [11] |
| netcup Failover-IP | gleicher netcup-Standort | 5,00 €/Monat inkl. MwSt. | Umrouten im SCP jederzeit kostenlos, nur innerhalb netcup [24][25] |
| Akamai/Linode IP-Sharing | ein Rechenzentrum, zwei Linodes | ab 5 USD (Nanode) | BGP-basiert (lelastic), nur im selben Rechenzentrum; NodeBalancer Basic 10 USD, TCP/HTTP [41][42] |
| Anycast / eigenes BGP | weltweit | hoch | Eigene 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] |
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
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]
Schritt für Schritt
- 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]
- PTR setzen: IP A auf mx1.example.com (Hetzner Console), IP B auf mx2.example.com (netcup SCP). [14][27]
- Grundinstallation per cloud-init (Debian 13): Deploy-Benutzer, Docker, WireGuard, keine Passwort-Anmeldung.
- WireGuard-Mesh 10.88.0.0/24 zwischen A, B, C; UDP 51820 nur für die Partner-IPs öffnen. [47]
- etcd auf allen drei (Election-Timeout mindestens 10 × RTT), dann Patroni + PostgreSQL 18 auf A und B. [50][53]
- Garage auf allen drei, Layout mit drei Zonen anwenden, Bucket und Schlüssel anlegen. [61]
- 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.
- 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.
- Backups wie in Kapitel 09 einrichten und eine Wiederherstellung testen. Dann einen Ausfall üben: Standort A hart abschalten und Kapitel 08 nachverfolgen.
#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:
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.
# 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 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]
#!/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: 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"]
Schritt für Schritt
- Drei VMs (oder Proxmox-Gäste) bei drei Providern, gleiche Debian-Version, je PTR auf mx1/mx2/mx3.
- Ansible-Inventar mit drei Hosts, ein Playbook für alle (WireGuard, etcd, Patroni, Garage, nextxch).
- Patroni-Konfiguration aus Setup S mit drittem Host; synchronous_mode: on, bei drei Datenbanken optional quorum. [54]
- Connection-String mit drei Hosts, Garage-Layout mit drei Zonen, drei MX-Einträge, drei IPs im SPF und in der DNS-Failover-Knotenliste.
- 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]
# 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 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
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
- Datenschicht wie Setup M aufbauen (drei VMs mit Patroni, etcd, Garage).
- Je Provider einen k3s- oder Talos-Cluster; Netzverbindung zur Datenschicht per WireGuard (oder KubeSpan).
- 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 stattdessenCluster__EnableProxyProtocol=true. Der Hetzner Cloud Load Balancer unterstützt PROXY Protocol und beliebige TCP-Dienste. [19] - Ausgehend Port 25: Pod-Egress über eine feste IP mit passendem PTR und SPF-Eintrag (Egress-Gateway, wie in deploy/kubernetes/README.md beschrieben).
- Helm-Werte pro Provider, Rollout per Pipeline nacheinander (Kapitel 07). HPA (2 bis 10 Pods bei 70 % CPU) und PodDisruptionBudget liefert das Repo mit.
# 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"]
# .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 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
| Zeit | Was passiert | Was Nutzer merken |
|---|---|---|
| 0 s | A 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 s | Der 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 s | Patroni 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 s | DNS-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 min | Resolver 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
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
- 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.
# /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 · 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"]
| Baustein | Skaliert | Hinweis |
|---|---|---|
| nextxch-Knoten / Pods | horizontal | Zustandslos; mehr Knoten pro Standort oder mehr Pods (HPA). Keine Sticky Sessions nötig. |
| PostgreSQL-Primary | vertikal | Alle 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-Speicher | horizontal | Weitere 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 DNS | Mehr MX-Einträge gleicher Priorität verteilen die Last; die Ausgangs-Queue verteilt sich über SKIP LOCKED auf alle Knoten. |
| IMAP | über DNS / LB | Langlebige 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 / LB | Zustandslose 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]
| Anbieter | Tarif | vCPU | RAM | Disk | € netto / Monat |
|---|---|---|---|---|---|
| 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 | Grundpreis inkl. 1 TB Speicher + 1 TB Egress | - | - | 1 TB | 6,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)
- 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]
- Neuen Host ins Ansible-Inventar mit eigener WireGuard-IP und Garage-Zone, Peers auf allen Standorten ergänzen, Playbook ausführen.
- etcd: Mitglied mit
etcdctl member addhinzufügen und mit ETCD_INITIAL_CLUSTER_STATE=existing starten. Bei vier Datenbank-Standorten auf eine ungerade Zahl von etcd-Mitgliedern achten. [48] - Patroni startet als Replikat und repliziert automatisch. Garage:
garage layout assignmit neuer Zone,garage layout apply. - 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.
- 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ücke | Auswirkung | Umgehung |
|---|---|---|
| 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-Ziel | Kein 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 Backup | Es gibt keinen Backup-Dienst für Datenbank und Blobs im Server. | pgBackRest und rclone (Kapitel 09). |
| Keine Lesereplikate | Alle Abfragen gehen an die Primary; Lesereplikate stehen im Entwicklungsplan nur als „auf Nachfrage“. | PostgreSQL-Primary vertikal skalieren. |
| Kein gemessener Sizing-Benchmark | Die 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.
- 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