nextxch en haute disponibilité sur plusieurs fournisseurs
Lorsqu'un fournisseur entier tombe, le courrier doit continuer à circuler : sans anycast coûteux, sans équilibreurs de charge d'entreprise, déployable depuis Git. Ce guide présente trois configurations sur des serveurs économiques chez Hetzner, netcup et STRATO, explique seconde par seconde ce qui se passe lors d'une panne et calcule ressources et coûts. Chaque affirmation sur un fournisseur renvoie à une source avec la date de vérification, chaque affirmation sur nextxch cite le fichier source dans le code.
01 · Ce que nextxch apporte pour la haute disponibilité (vérifié dans le code)
nextxch est compatible actif-actif : chaque nœud accepte SMTP, IMAP, JMAP, ActiveSync et HTTPS, il n'y a ni nœud passif ni session persistante. PostgreSQL contient l'état partagé, les messages eux-mêmes sont stockés sous forme d'objets .eml adressés par SHA-256 dans le stockage de blobs.
| Fonctionnalité | Comment | Source dans le code |
|---|---|---|
| Backplane de cluster sans Redis | PostgreSQL LISTEN/NOTIFY (canal nextxch_cluster_events) pour des événements tels que nouveau message, indicateurs, expunge, file d'attente ; tables UNLOGGED cluster_ephemeral et cluster_nodes ; heartbeat toutes les 15 s, nœuds retirés après 60 s sans heartbeat | src/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs |
| Tâches du leader | pg_try_advisory_lock : exactement un nœud renouvelle les certificats ACME, envoie le heartbeat de licence, synchronise LDAP et envoie les rappels de calendrier | AcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs |
| File d'attente SMTP répartie sur les nœuds | FOR UPDATE SKIP LOCKED : chaque nœud réserve des éléments de file d'attente différents, pas de double remise | src/Nextxch.Storage/Postgres/PostgresMailStore.cs |
| Bases de données compatibles cluster | PostgreSQL (par défaut pour les clusters) et SQL Server (sp_getapplock) ; SQLite en nœud unique uniquement | src/Nextxch.Storage/StorageServiceCollectionExtensions.cs |
| Stockage de blobs | FileSystem, S3 (client SigV4 propre avec Content-MD5) ou Hybrid (cache NVMe local + S3). Modes Object Lock Normal, Governance, Compliance et conservation légale (legal hold) | src/Nextxch.Storage/Blobs/S3BlobStore.cs, HybridBlobStore.cs |
| Certificats & DKIM | Les certificats ACME et les clés DKIM résident dans la base de données ; les challenges HTTP-01 sont partagés dans tout le cluster afin que n'importe quel nœud puisse répondre ; la clé feuille est conservée lors du renouvellement (DANE 3 1 1 reste valide) | src/Nextxch.Security/Tls/AcmeCertificateManager.cs |
| Points de terminaison de santé | /health/live (sans BD), /health/ready (BD joignable, sinon 503), /health (vue d'ensemble, pairs du cluster compris) | src/Nextxch.Server/Program.cs |
| IP réelle du client | Protocole PROXY v1/v2 pour SMTP, IMAP, ManageSieve (Cluster:EnableProxyProtocol) et X-Forwarded-For pour HTTP depuis TrustedProxyCidrs | src/Nextxch.Core/Abstractions/IClusterCoordinator.cs |
| Artefacts de déploiement | docker-compose.yml, manifestes Kubernetes (3 réplicas, HPA de 2 à 10, PDB, sondes), chart Helm, deb/rpm, MSI Windows | deploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/ |
| Licence | Ce sont les boîtes aux lettres actives qui sont comptées, pas les nœuds ni les pods ; l'état de la licence est partagé via cluster_kv | src/Nextxch.Server/Services/ClusterLicenseService.cs, Tarifs |
Ports par nœud : 25, 465, 587 (SMTP), 143, 993 (IMAP), 4190 (ManageSieve), 80/443 (HTTPS, ACME, webmail, ActiveSync, JMAP). POP3 110/995 est désactivé par défaut (Pop3:Enabled=false dans Pop3Settings.cs).
/health/ready renvoie 503 et le nœud ne peut pas vérifier les destinataires. La haute disponibilité entre fournisseurs consiste donc à répliquer PostgreSQL et le stockage de blobs entre les sites et à basculer automatiquement. C'est ce que mettent en place les trois configurations.02 · Règles propres à la messagerie qui facilitent la haute disponibilité
Entrant : le MX intègre le basculement
Les expéditeurs trient les enregistrements MX par préférence et doivent essayer chaque adresse ; à préférence égale, ils doivent choisir au hasard. [1] Si une cible est injoignable, l'intervalle entre les tentatives doit être d'au moins 30 minutes et le délai d'abandon d'au moins 4 à 5 jours. [1]
Sortant : SPF et PTR par IP
Le SPF doit lister chaque IP sortante de chaque site ; ip4/ip6 ne comptent pas dans la limite de 10 requêtes DNS. [2] Gmail exige un DNS direct et inverse valide ; le PTR se configure séparément chez chaque fournisseur. [8][14][27][31]
DKIM, MTA-STS, DANE
Les clés DKIM résident dans la base de données du cluster, un seul jeu DNS sert tous les nœuds ; les sélecteurs permettent plusieurs clés en parallèle. [3] La politique MTA-STS doit nommer chaque hôte MX ; les caractères génériques ne couvrent que le label le plus à gauche. [4] DANE nécessite un enregistrement TLSA par hôte MX, 3 1 1 est recommandé, ainsi qu'une zone signée DNSSEC. [5]
Clients : IMAP, soumission, HTTPS
Les clients de messagerie n'ont pas de basculement de type MX. Ils se connectent à un nom unique (ici mail.example.com) dont les enregistrements A sont gérés par une tâche de contrôle de santé. Les connexions IDLE sont renouvelées au moins toutes les 29 minutes et aboutissent alors sur un nœud sain. [7][6]
; 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 dans la configuration ou dans /admin/ sous "Hostname, IPv6 & Ports" (nom EHLO par nœud, avec contrôle PTR par nœud). Il est utilisé pour la bannière 220, la réponse EHLO et l'EHLO sortant. Faites pointer le PTR de chaque IP vers son nom fixe mx1/mx2 ; le conseiller DNS affiche un enregistrement SPF HELO par nœud. La RFC 5321, section 4.1.4, interdit de toute façon de refuser un message uniquement en raison d'un nom EHLO non concordant. [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 · Redirection des clients : comparaison des options
Les IP de basculement d'un fournisseur n'aident qu'au sein d'un même fournisseur. Pour la panne d'un fournisseur entier, le DNS reste l'outil simple et peu coûteux.
| Option | Portée | Coût | Évaluation |
|---|---|---|---|
| Plusieurs MX de même préférence | sur plusieurs fournisseurs | 0 € | Obligatoire pour le courrier entrant, normalisé [1] |
| Tâche de contrôle d'état propre + API DNS (Cloudflare, Hetzner, deSEC, netcup) | sur plusieurs fournisseurs | 0 € | Recommandé. Cloudflare : TTL à partir de 60 s (Enterprise 30 s), auto = 300 s. Hetzner DNS : gratuit, TTL à partir de 60 s, API dans la Hetzner Console. deSEC : TTL minimal par domaine, par exemple 3600 s, plus bas uniquement sur demande. netcup : une API DNS CCP existe, TTL minimal non documenté. [38][39][21][40][29] |
| Cloudflare Load Balancing (DNS uniquement) | sur plusieurs fournisseurs | à partir de 5 USD/mois | Contrôles de santé (TCP, SMTP compris) toutes les 15 s hors Enterprise ; pour A/AAAA/CNAME, MX/SRV uniquement avec Enterprise. Remplace la tâche propre pour mail.example.com. [33][34][35][36] |
| Cloudflare Spectrum | sur plusieurs fournisseurs | Enterprise | Ports TCP arbitraires (25, 465, 587, 993) uniquement en module complémentaire Enterprise ; Pro/Business uniquement SSH, Minecraft, RDP. [37] |
| Hetzner Floating IP (Cloud) | un projet, une zone réseau | IPv4 3,00 €, IPv6 1,00 €/mois | Uniquement au sein de Hetzner [9] |
| IP failover Hetzner (Robot, dédié) | Centres de données Hetzner | 4,20 €/mois + 4,90 € de frais d'installation | Bascule via l'API Robot en 90 à 110 s, uniquement au sein de Hetzner [11] |
| IP de basculement netcup | même site netcup | 5,00 €/mois TTC | Réacheminement dans le SCP à tout moment et gratuitement, uniquement au sein de netcup [24][25] |
| Partage d'IP Akamai/Linode | un seul centre de données, deux Linodes | à partir de 5 USD (Nanode) | Basé sur BGP (lelastic), uniquement dans le même centre de données ; NodeBalancer Basic 10 USD, TCP/HTTP [41][42] |
| Anycast / BGP propre | global | élevé | Propre ASN (contrat RIPE via adhésion ou LIR parrain) plus propre espace d'adressage (chez Vultr au moins /24 en IPv4 ou /48 en IPv6). Disproportionné pour la plupart des installations nextxch. [43][44] |
04 · Configuration S : deux sites plus un témoin
Pour les petites et moyennes entreprises. Le site A (par ex. Hetzner Cloud) et le site B (par ex. netcup VPS) exécutent chacun nextxch, PostgreSQL sous Patroni, un membre etcd et une zone Garage. Un petit témoin C chez un troisième fournisseur (par ex. STRATO) héberge le troisième membre etcd, la troisième copie Garage et le contrôleur de basculement DNS. Les trois sont maillés via WireGuard.
flowchart TB
U["Users: IMAP, SMTP submission, HTTPS"] -->|"mail.example.com TTL 60"| DNS["DNS with health-check job"]
S["Remote mail servers"] -->|"MX 10 mx1 / MX 10 mx2"| A1
S --> B1
DNS --> A1
DNS --> B1
subgraph A["Site A: Hetzner"]
A1["nextxch"] --> AP[("PostgreSQL primary")]
A1 --> AG["Garage zone A"]
AE["etcd"]
end
subgraph B["Site B: netcup"]
B1["nextxch"] --> BP[("PostgreSQL replica")]
B1 --> BG["Garage zone B"]
BE["etcd"]
end
subgraph C["Witness C: STRATO"]
CE["etcd"]
CG["Garage zone C"]
CF["Failover checker"]
end
B1 -.->|"WireGuard"| AP
AP ==>|"synchronous replication"| BP
AG <-.-> BG
BG <-.-> CG
AE <-.-> BE
BE <-.-> CE
Pourquoi Garage comme stockage de blobs ?
Un bucket S3 chez un seul fournisseur disparaîtrait avec ce fournisseur, et nextxch ne peut actuellement écrire que vers une seule cible S3. Garage est un stockage compatible S3 (AGPL-3.0) conçu pour plusieurs centres de données reliés par internet (exigence du projet : latence de 200 ms au plus, au moins 50 Mbit/s). Avec replication_factor = 3, il y a une copie par zone ; lectures et écritures se poursuivent tant qu'une seule zone tombe en panne (quorum d'écriture 2). Avec seulement deux zones, la perte d'une zone laisserait le stockage en lecture seule, c'est pourquoi le témoin détient lui aussi une copie complète. [60][61]
Étape par étape
- Commander les serveurs : A = Hetzner Cloud (les ports 25/465 sont bloqués par défaut ; demandez le déblocage après un mois et une première facture payée, le 587 est ouvert), B = netcup VPS (supprimez la politique de pare-feu "netcup Mail block" dans le SCP), C = STRATO VPS comme témoin (le témoin n'a pas besoin du port 25). [13][26]
- Définir le PTR : IP A vers mx1.example.com (Hetzner Console), IP B vers mx2.example.com (netcup SCP). [14][27]
- Installation de base via cloud-init (Debian 13) : utilisateur de déploiement, Docker, WireGuard, pas de connexion par mot de passe.
- Maillage WireGuard 10.88.0.0/24 entre A, B, C ; n'ouvrez UDP 51820 que pour les IP des pairs. [47]
- etcd sur les trois (election timeout d'au moins 10 × RTT), puis Patroni + PostgreSQL 18 sur A et B. [50][53]
- Garage sur les trois, appliquez un layout à trois zones, créez le bucket et la clé. [61]
- nextxch via Docker Compose sur A et B (Cluster:Enabled=true, chaîne de connexion multi-hôtes, S3 pointant vers le Garage local). Dans /admin/, définissez le nom d'hôte mail.example.com et émettez le certificat ACME pour mail, mx1, mx2.
- DNS comme au chapitre 02, vérifiez la liste MX MTA-STS dans /admin/, activez le basculement DNS dans /admin/ sous Domaines (seul le leader du cluster agit) ou, à défaut, le script sous un verrou etcd.
- Sauvegardes comme au chapitre 09, et testez une restauration. Ensuite, répétez une panne : arrêtez brutalement le site A et suivez le chapitre 08.
#cloud-config
# Hetzner Cloud / netcup: paste as user data when creating the VM.
users:
- name: deploy
groups: [sudo, docker]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ssh-ed25519 AAAA... ci-deploy
package_update: true
packages: [wireguard, curl, jq, docker.io, docker-compose-v2, unattended-upgrades]
runcmd:
- [ systemctl, enable, --now, docker ]
- [ sed, -i, "s/^#\\?PasswordAuthentication.*/PasswordAuthentication no/", /etc/ssh/sshd_config ]
- [ systemctl, restart, ssh ]
# /etc/wireguard/wg0.conf on site A (Hetzner). Same pattern on B and C.
# Full mesh: every site has one [Peer] block per other site.
[Interface]
Address = 10.88.0.1/24
ListenPort = 51820
PrivateKey = <private-key-of-site-A> # wg genkey, never commit it
[Peer] # site B (netcup)
PublicKey = <public-key-of-site-B>
Endpoint = 198.51.100.20:51820
AllowedIPs = 10.88.0.2/32
PersistentKeepalive = 25
[Peer] # site C (witness or third site)
PublicKey = <public-key-of-site-C>
Endpoint = 203.0.113.30:51820
AllowedIPs = 10.88.0.3/32
PersistentKeepalive = 25
# systemctl enable --now wg-quick@wg0
# Firewall: UDP 51820 open only for the two peer IPs.
# /etc/default/etcd on site A (Debian package etcd-server). Sites B and C analogue.
# Three members = quorum of two: any single site may fail.
ETCD_NAME="site-a"
ETCD_DATA_DIR="/var/lib/etcd/default"
ETCD_LISTEN_PEER_URLS="http://10.88.0.1:2380"
ETCD_LISTEN_CLIENT_URLS="http://10.88.0.1:2379,http://127.0.0.1:2379"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://10.88.0.1:2380"
ETCD_ADVERTISE_CLIENT_URLS="http://10.88.0.1:2379"
ETCD_INITIAL_CLUSTER="site-a=http://10.88.0.1:2380,site-b=http://10.88.0.2:2380,site-c=http://10.88.0.3:2380"
ETCD_INITIAL_CLUSTER_STATE="new"
ETCD_INITIAL_CLUSTER_TOKEN="nextxch-ha"
# WAN tuning (etcd tuning guide: election timeout at least 10x the round trip).
# Example for up to ~25 ms RTT between providers:
ETCD_HEARTBEAT_INTERVAL="250"
ETCD_ELECTION_TIMEOUT="2500"
# Traffic stays inside WireGuard. For production add TLS (ETCD_CERT_FILE etc.).
# /etc/patroni/config.yml on site A (Debian/PGDG package "patroni").
# Site B: name/addresses analogue. The witness C runs etcd only, no PostgreSQL.
scope: nextxch
name: pg-site-a
restapi:
listen: 10.88.0.1:8008
connect_address: 10.88.0.1:8008
etcd3:
hosts: 10.88.0.1:2379,10.88.0.2:2379,10.88.0.3:2379
bootstrap:
dcs:
ttl: 30 # defaults; rule: loop_wait + 2 * retry_timeout <= ttl
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
synchronous_mode: on # a 250 OK to a sender is only given after the commit reached a 2nd site
synchronous_mode_strict: false # if the replica site is gone, keep accepting mail (falls back to async)
failsafe_mode: true
postgresql:
use_pg_rewind: true
parameters:
wal_level: replica
hot_standby: "on"
max_wal_senders: 10
max_replication_slots: 10
wal_log_hints: "on"
archive_mode: "on"
archive_command: "pgbackrest --stanza=nextxch archive-push %p"
initdb: [ {encoding: UTF8}, data-checksums ]
postgresql:
listen: 10.88.0.1:5432
connect_address: 10.88.0.1:5432
data_dir: /var/lib/postgresql/18/main
bin_dir: /usr/lib/postgresql/18/bin
authentication:
replication: { username: replicator, password: "${PATRONI_REPLICATION_PASSWORD}" }
superuser: { username: postgres, password: "${PATRONI_SUPERUSER_PASSWORD}" }
pg_hba:
- host replication replicator 10.88.0.0/24 scram-sha-256
- host nextxch nextxch 10.88.0.0/24 scram-sha-256
# /etc/garage.toml, identical on all three sites except rpc_public_addr.
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 3 # one copy per zone (site); writes need 2 of 3
consistency_mode = "consistent"
rpc_bind_addr = "10.88.0.1:3901"
rpc_public_addr = "10.88.0.1:3901"
rpc_secret = "<openssl rand -hex 32, same on all nodes>"
[s3_api]
s3_region = "garage"
api_bind_addr = "127.0.0.1:3900" # each nextxch node talks to its local Garage
# After the first start (node ids from `garage node id`):
# garage layout assign <id-a> -z hetzner -c 500G
# garage layout assign <id-b> -z netcup -c 500G
# garage layout assign <id-c> -z witness -c 500G
# garage layout show && garage layout apply --version 1
# garage bucket create nextxch
# garage key create nextxch-app
# garage bucket allow --read --write --owner nextxch --key nextxch-app
# docker-compose.yml on site A and site B (nextxch only; PostgreSQL/Patroni, etcd,
# Garage and WireGuard run as system services on the same VM).
# Secrets come from .env (never committed): NEXTXCH_LICENSE, PG_PASSWORD, S3_KEY, S3_SECRET
name: nextxch
services:
nextxch:
image: nextxch/server:${NEXTXCH_TAG}
restart: unless-stopped
network_mode: host # real client IPs for SPF/DMARC, direct WireGuard reach
environment:
ASPNETCORE_URLS: "http://*:80;https://*:443"
License__Token: "${NEXTXCH_LICENSE}"
Cluster__Enabled: "true"
Cluster__NodeId: "${SITE_NAME}" # hetzner-a / netcup-b
Storage__Provider: "Postgres"
# Npgsql multi-host: always connects to the current Patroni primary
Storage__PostgresConnectionString: "Host=10.88.0.1,10.88.0.2;Port=5432;Database=nextxch;Username=nextxch;Password=${PG_PASSWORD};Target Session Attributes=primary;Host Recheck Seconds=5"
Storage__BlobBackend: "S3"
Storage__Compression: "Zstd"
Storage__S3__EndpointUrl: "http://127.0.0.1:3900"
Storage__S3__Region: "garage"
Storage__S3__BucketName: "nextxch"
Storage__S3__ForcePathStyle: "true"
Storage__S3__AccessKey: "${S3_KEY}"
Storage__S3__SecretKey: "${S3_SECRET}"
Smtp__ServerHostName: "mail.example.com"
Imap__ServerHostName: "mail.example.com"
ManageSieve__ServerHostName: "mail.example.com"
Acme__Enabled: "true"
Acme__ContactEmail: "postmaster@example.com"
volumes:
- nextxch-data:/app/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1/health/ready"]
interval: 15s
timeout: 5s
retries: 3
volumes:
nextxch-data:
La chaîne de connexion nomme les deux hôtes PostgreSQL avec Target Session Attributes=primary. nextxch crée ses connexions via NpgsqlDataSource.Create (PostgresMailStore.cs) ; avec plusieurs hôtes, Npgsql construit automatiquement une source de données multi-hôte et ne se connecte qu'au primaire actuel, en revérifiant l'état toutes les Host Recheck Seconds (par défaut 10 s). Après un basculement Patroni, les nœuds se reconnectent sans aucune modification de configuration ; la connexion LISTEN du cluster réessaie toutes les 2 s (PostgresClusterCoordinator.cs). [63]
Basculement DNS automatique pour mail.example.com
nextxch fournit un agent de basculement DNS (à activer, désactivé par défaut ; /admin/ sous Domaines, carte « DNS failover »). Toutes les 30 s, chaque nœud vérifie la bannière SMTP sur le port 25 et le HTTPS /health de chaque nœud via son IP publique. Seul le leader du cluster agit : il détient un verrou consultatif PostgreSQL, combine à la majorité la vue de tous les nœuds et écrit les enregistrements A/AAAA des noms configurés via l'API Hetzner DNS (Cloud API) ou Cloudflare. Hystérésis : après N cycles en échec, un nœud sort du DNS, après M cycles sains il y revient ; le dernier nœud n'est jamais retiré. Chaque modification figure dans le journal des événements, et la règle d'alerte « DNS failover: node taken out » notifie via les alertes d'exploitation. Le jeton d'API est chiffré avec le trousseau de clés du serveur et n'est plus jamais affiché.
# 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 dans la chaîne de connexion (chapitre 04 ci-dessus), un côté isolé n'atteint aucun primaire, n'obtient pas le verrou et reste passif. Le vote majoritaire de tous les observateurs empêche un nœud isolé ayant une vue erronée d'écarter un nœud sain.Alternative : script sous un verrou etcd
Pour les fournisseurs DNS sans connecteur intégré (par ex. deSEC), ou lorsque etcd doit prendre la décision, le script reste une alternative équivalente. N'exécutez pas l'agent et le script en même temps pour les mêmes noms. Toutes les 20 s, le script vérifie /health/ready de chaque nœud via son IP publique et réconcilie les enregistrements A via l'API Cloudflare (PATCH/POST/DELETE sur dns_records). Il s'exécute sur chaque site sous etcdctl lock : une seule copie agit, et uniquement du côté qui détient la majorité etcd. Un site isolé ne peut donc plus modifier le DNS. Pour Hetzner DNS ou deSEC, seuls les trois appels curl changent. [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 · Configuration M : trois sites complets (Proxmox ou VM simples)
Comme la configuration S, mais les trois sites exécutent nextxch avec un réplica PostgreSQL. En cas de panne d'un site, il reste deux bases de données et deux nœuds nextxch, et avec synchronous_mode la réplication synchrone est même conservée. Trois enregistrements MX de même préférence répartissent la charge entrante.
flowchart TB
S["Remote mail servers"] -->|"MX 10 mx1, mx2, mx3"| N
U["Users"] -->|"mail.example.com"| N
subgraph N["WireGuard mesh 10.88.0.0/24"]
direction LR
subgraph A["Hetzner"]
A1["nextxch"] --- A2[("Patroni + PG")] --- A3["etcd + Garage"]
end
subgraph B["netcup"]
B1["nextxch"] --- B2[("Patroni + PG")] --- B3["etcd + Garage"]
end
subgraph C["Third provider"]
C1["nextxch"] --- C2[("Patroni + PG")] --- C3["etcd + Garage"]
end
end
N --> BK["Backups: Storage Box + S3 at another provider"]
Étape par étape
- Trois VM (ou invités Proxmox) chez trois fournisseurs, même version de Debian, PTR vers mx1/mx2/mx3 respectivement.
- Inventaire Ansible avec trois hôtes, un seul playbook pour tous (WireGuard, etcd, Patroni, Garage, nextxch).
- Configuration Patroni de la configuration S avec un troisième hôte ; synchronous_mode: on, avec trois bases de données éventuellement en quorum. [54]
- Chaîne de connexion avec trois hôtes, layout Garage avec trois zones, trois enregistrements MX, trois IP dans le SPF et dans la liste des nœuds du basculement DNS.
- Proxmox Backup Server en option pour les images de VM : datastore local ou S3 (backend S3 officiellement pris en charge depuis PBS 4.2) ; Proxmox ne documente pas les partages réseau comme une Storage Box via CIFS comme type de datastore. [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 · Configuration L : Kubernetes chez trois fournisseurs
Pour de nombreuses boîtes aux lettres et une mise à l'échelle horizontale. Point important d'abord : k3s ne prend explicitement pas en charge etcd intégré dans un cluster réparti sur plusieurs clouds ; les nœuds serveur doivent rester sur un seul site. D'où : un cluster Kubernetes par fournisseur (k3s ou Talos), chacun exécutant un déploiement nextxch issu du chart Helm fourni. La couche de données (Patroni + etcd + Garage) fonctionne sur ses propres VM via WireGuard, comme dans la configuration M, et est utilisée par les trois clusters. [49][48]
flowchart TB
DNS["DNS: MX per provider LB, mail.example.com with health checks"]
subgraph K1["k3s/Talos cluster Hetzner"]
L1["Load balancer"] --> P1["nextxch pods (HPA)"]
end
subgraph K2["k3s/Talos cluster netcup"]
L2["Load balancer"] --> P2["nextxch pods (HPA)"]
end
subgraph K3["k3s/Talos cluster STRATO"]
L3["Load balancer"] --> P3["nextxch pods (HPA)"]
end
DNS --> L1
DNS --> L2
DNS --> L3
subgraph D["Data layer on VMs, WireGuard"]
PG[("Patroni: primary + 2 replicas")]
GA["Garage: 3 zones"]
ET["etcd: 3 members"]
end
P1 --> PG
P2 --> PG
P3 --> PG
P1 --> GA
P2 --> GA
P3 --> GA
Pourquoi pas CloudNativePG entre clusters ?
CloudNativePG peut exécuter des clusters réplicas dans d'autres clusters Kubernetes, mais la promotion et la rétrogradation entre clusters sont déclaratives (champ primary, promotionToken), donc non automatiques. Pour un basculement automatique entre fournisseurs, Patroni avec etcd sur trois sites est la solution la plus directe. [52]
Un seul cluster pour tous les fournisseurs ?
Talos propose KubeSpan, un maillage complet WireGuard entre les sites, et k3s un backend flannel wireguard-native. Le plan de contrôle (etcd) est toutefois sensible à la latence WAN. Plusieurs petits clusters sont plus simples à exploiter et tombent en panne indépendamment. [51][49][50]
Étape par étape
- Construisez la couche de données comme dans la configuration M (trois VM avec Patroni, etcd, Garage).
- Un cluster k3s ou Talos par fournisseur ; liaison réseau à la couche de données via WireGuard (ou KubeSpan).
- Ports entrants : service de type LoadBalancer avec
externalTrafficPolicy: Local(comme dans le dépôt) afin de conserver l'IP de l'expéditeur pour SPF/DMARC ; avec un load balancer qui parle le protocole PROXY, utilisez plutôtCluster__EnableProxyProtocol=true. Le Hetzner Cloud Load Balancer prend en charge le protocole PROXY et tout service TCP. [19] - Port 25 sortant : egress des pods via une IP fixe avec PTR et entrée SPF correspondants (passerelle egress, comme décrit dans deploy/kubernetes/README.md).
- Valeurs Helm par fournisseur, déploiement via pipeline l'un après l'autre (chapitre 07). HPA (2 à 10 pods à 70 % de CPU) et PodDisruptionBudget sont fournis avec le dépôt.
# 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 · Déployer et mettre à jour depuis Git
Tout ce qui est important est un fichier dans le dépôt : fichier compose ou valeurs Helm, inventaire Ansible, script de basculement DNS. Le pipeline ne déploie toujours qu'un seul site à la fois et attend que /health/ready indique de nouveau « ready » avant de continuer. Les secrets (licence, mots de passe, jetons API, clés SSH) résident uniquement dans les coffres de secrets GitHub/GitLab ou dans le .env du serveur, jamais dans le dépôt.
flowchart TB
G["git push to main"] --> V["Validate: compose config / helm lint"]
V --> A["Roll out site A"]
A --> HA{"/health/ready = ready?"}
HA -->|"yes"| B["Roll out site B"]
HA -->|"no"| X["Abort, B stays on old version"]
B --> HB{"/health/ready = ready?"}
HB -->|"yes"| C["Roll out site C"]
HB -->|"no"| X
C --> OK["Done"]
# .github/workflows/deploy.yml: rolling update of the compose sites, one site at a time.
name: deploy-nextxch
on:
push:
branches: [main]
paths: ["sites/**", "compose/**"]
workflow_dispatch:
concurrency: { group: deploy-nextxch, cancel-in-progress: false }
jobs:
deploy:
runs-on: ubuntu-latest
strategy:
max-parallel: 1 # never update two sites at once
matrix:
site: [hetzner-a, netcup-b]
environment: ${{ matrix.site }}
steps:
- uses: actions/checkout@v4
- name: Write SSH key
run: |
install -m 700 -d ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
echo "${{ vars.HOST_KEY }}" >> ~/.ssh/known_hosts
- name: Upload compose file and roll the container
env:
HOST: ${{ vars.SSH_HOST }}
run: |
scp compose/docker-compose.yml "deploy@${HOST}:/opt/nextxch/docker-compose.yml"
scp "sites/${{ matrix.site }}.env.tag" "deploy@${HOST}:/opt/nextxch/tag.env"
ssh "deploy@${HOST}" 'cd /opt/nextxch && docker compose --env-file .env --env-file tag.env pull && docker compose --env-file .env --env-file tag.env up -d'
- name: Wait until the node is ready again
env:
HOST: ${{ vars.SSH_HOST }}
run: |
for i in $(seq 1 30); do
ssh "deploy@${HOST}" 'curl -fsS http://127.0.0.1/health/ready' && exit 0
sleep 10
done
echo "node did not become ready" >&2; exit 1
# .gitlab-ci.yml: the same rolling update with GitLab CI
stages: [validate, deploy-a, deploy-b]
variables:
NEXTXCH_TAG: "latest" # pin a released version in production
validate:
stage: validate
image: docker:27
script:
- docker compose -f compose/docker-compose.yml config -q
.deploy: &deploy
image: alpine:3.22
before_script:
- apk add --no-cache openssh-client curl
- install -m 700 -d ~/.ssh && echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
- echo "$HOST_KEY" >> ~/.ssh/known_hosts
script:
- scp compose/docker-compose.yml "deploy@$SSH_HOST:/opt/nextxch/"
- ssh "deploy@$SSH_HOST" "cd /opt/nextxch && NEXTXCH_TAG=$NEXTXCH_TAG docker compose pull && NEXTXCH_TAG=$NEXTXCH_TAG docker compose up -d"
- for i in $(seq 1 30); do ssh "deploy@$SSH_HOST" 'curl -fsS http://127.0.0.1/health/ready' && exit 0; sleep 10; done; exit 1
resource_group: production # serializes deployments
rules: [ { if: '$CI_COMMIT_BRANCH == "main"' } ]
deploy-hetzner-a:
<<: *deploy
stage: deploy-a
environment: { name: hetzner-a }
deploy-netcup-b:
<<: *deploy
stage: deploy-b
environment: { name: netcup-b }
# .github/workflows/deploy-k8s.yml: one Kubernetes cluster per provider, rolled one after another.
name: deploy-nextxch-k8s
on:
push: { branches: [main], paths: ["k8s/**"] }
workflow_dispatch:
concurrency: { group: deploy-k8s, cancel-in-progress: false }
jobs:
helm:
runs-on: ubuntu-latest
strategy:
max-parallel: 1
matrix:
cluster: [hetzner, netcup, strato]
environment: k8s-${{ matrix.cluster }}
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
- name: Kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Helm upgrade (waits for readiness probes on /health/ready)
run: |
helm upgrade --install nextxch ./deploy/helm/nextxch \
--namespace nextxch --create-namespace \
-f k8s/values-common.yaml -f k8s/values-${{ matrix.cluster }}.yaml \
--set image.tag=${{ vars.NEXTXCH_TAG }} \
--wait --timeout 10m --atomic
nextxch-cli config plan commente la pull request avec le plan, config apply --prune --yes l'applique après le merge. Guide avec des fichiers GitHub Actions et GitLab CI prêts à l'emploi : Configuration as code (GitOps).08 · Scénarios de panne : ce qui se passe exactement
Hetzner (site A, primaire actuel) est entièrement hors ligne
sequenceDiagram participant S as Remote mail server participant A as Site A (Hetzner) participant B as Site B (netcup) participant C as Witness C participant D as DNS Note over A: Provider outage S->>A: SMTP to mx1 A--xS: no connection S->>B: SMTP to mx2 (or retry) B--xS: without a primary the session fails, sender retries later Note over B,C: etcd majority B + C, leader key of A expires (ttl 30 s) B->>B: Patroni promotes PostgreSQL B to primary B->>B: nextxch reconnects (Target Session Attributes=primary) S->>B: retry B-->>S: 250 OK (commit synchronous or asynchronous) C->>D: failover job removes IP A from mail.example.com Note over D: clients follow after the TTL expires
| Durée | Ce qui se passe | Ce que les utilisateurs remarquent |
|---|---|---|
| 0 s | A est hors service. Le courrier entrant vers mx1 échoue, les expéditeurs utilisent mx2 (même préférence) ou réessaient plus tard. [1] | Les clients sur A perdent leur connexion. |
| jusqu'à 30 s environ | La clé leader de A dans etcd expire après ttl = 30 s ; B et C détiennent la majorité. [53] | Le nœud B renvoie 503 sur /health/ready, les écritures attendent. |
| environ 30 à 60 s | Patroni promeut B (loop_wait 10 s). nextxch sur B trouve le nouveau primaire via la chaîne de connexion multi-hôtes (nouvelle vérification en 5 à 10 s). [63] | B accepte de nouveau le courrier. |
| environ 60 à 90 s | Le basculement DNS (agent : 3 tours échoués × 30 s, script : 3 × 20 s) retire l'IP A de mail.example.com. | Les nouvelles connexions vont vers B dès que le TTL (60 s) a expiré. |
| 1 à 5 min | Les résolveurs avec leurs propres TTL minimaux et les clients avec des caches DNS suivent. Garage continue avec les zones B et C (quorum de 2 sur 3). [61] | Les clients se reconnectent ; les sessions webmail restent valides (sessions signées, sans état). |
RPO
Avec synchronous_mode, PostgreSQL ne confirme un commit qu'une fois que le second site l'a reçu ; un message confirmé par 250 OK existe alors sur deux sites. Le prix : chaque commit attend au moins un aller-retour entre les sites. Sans synchronous_mode, les dernières secondes avant la panne peuvent manquer. Avec synchronous_mode_strict: false, Patroni repasse en asynchrone lorsqu'il ne reste plus de réplique, de sorte que le courrier continue d'être accepté. [56][54]
RTO
Base de données environ 30 à 60 s (valeurs par défaut de Patroni), bascule DNS environ 1 min plus le TTL, courrier entrant sans attente via le second MX ou la nouvelle tentative de l'expéditeur. Ces durées sont déduites des valeurs par défaut, non mesurées : vérifiez-les par votre propre test (arrêt brutal d'un site).
netcup (site B, réplica) est hors ligne
Le primaire sur A continue de fonctionner. Sans partenaire synchrone, Patroni passe en mode asynchrone (strict = false), la tâche de basculement retire l'IP B, le courrier entrant passe par mx1. Garage continue avec A et C.
Seule la liaison entre A et B est coupée
Le côté qui atteint encore le témoin C conserve la majorité etcd et reste ou devient primaire. L'autre côté perd sa majorité, Patroni y rétrograde un primaire (failsafe_mode ne s'applique que s'il peut encore atteindre tous les membres) et le basculement DNS n'y agit pas : l'agent intégré n'atteint aucun primaire et n'obtient pas le verrou leader, le script n'obtient pas le verrou etcd. Aucun split brain ne se produit. [55]
Retour arrière (failback)
sequenceDiagram participant A as Site A participant B as Site B (primary) participant G as Garage participant D as DNS job A->>A: server back online, WireGuard up A->>B: Patroni: pg_rewind, then replica of B G->>A: missing objects are re-replicated A->>A: /health/ready = ready D->>D: successful checks D->>A: IP A back in mail.example.com Note over A,B: optional, planned: patronictl switchover back to A
Lorsque A revient, B reste primaire. Patroni réintègre A comme réplique avec pg_rewind (use_pg_rewind: true), Garage resynchronise les objets manquants, la tâche de basculement rajoute l'IP A dès que /health/ready y indique "ready". Il n'est pas nécessaire de rebasculer le primaire ; si vous le souhaitez, faites-le de manière planifiée avec patronictl switchover à un moment calme.
09 · Sauvegarde, restauration et GoBD
La réplication protège contre les pannes, pas contre les erreurs : une boîte aux lettres supprimée par mégarde disparaît sur tous les sites à la fois. Ajoutez donc des sauvegardes selon la règle 3-2-1 : trois copies, deux types de supports, une copie hors site. [59]
flowchart LR
PG[("PostgreSQL primary or replica")] -->|"pgBackRest, continuous WAL"| R1["Repo 1: Hetzner Storage Box (SFTP)"]
PG -->|"pgBackRest"| R2["Repo 2: S3 at another provider"]
GA["Garage (3 zones)"] -->|"rclone copy hourly"| AR["Object Storage with Object Lock (GoBD)"]
GA -->|"rclone copy"| R1
R1 --> T["Quarterly restore test"]
R2 --> T
- PostgreSQL : pgBackRest avec archivage WAL continu (restauration à un instant donné). Dépôt 1 sur une Hetzner Storage Box via SFTP (pgBackRest prend en charge SFTP depuis la 2.46 ; la Storage Box propose SFTP, SCP, rsync, BorgBackup, Restic, Rclone, SMB, WebDAV), dépôt 2 sur S3 chez un autre fournisseur. Les deux chiffrés. [58][15]
- Blobs : les objets .eml sont nommés d'après leur SHA-256 et ne sont jamais modifiés.
rclone copy(et non sync, donc sans jamais supprimer) suffit comme copie horaire. - Archive GoBD : Hetzner Object Storage prend en charge Object Lock en mode GOVERNANCE et COMPLIANCE ainsi que la gestion des versions ; Object Lock doit être activé à la création du bucket. Créez le bucket avec une conservation par défaut (compliance) : chaque objet copié est alors verrouillé automatiquement. Vous pouvez aussi laisser nextxch écrire directement dans ce bucket (backend S3 ou Hybrid), mais la disponibilité des blobs dépend alors de Hetzner. Dans ce cas, nextxch crée au démarrage un bucket manquant avec Object Lock dès qu'un mode WORM est configuré (ou
Storage:S3:ObjectLockEnabled) ; "Vérifier le bucket S3" dans l'archive GoBD indique l'accessibilité, la gestion des versions, le mode Object Lock et la conservation par défaut. [18][17] - Tests de restauration : au moins une fois par trimestre, restaurez pgBackRest dans une VM de test et ouvrez des échantillons de la copie des blobs.
# /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 · Montée en charge : utilisateurs, stockage, pods
flowchart LR S1["Start: 2 sites + witness"] --> S2["More mailboxes: add nextxch nodes per site"] S2 --> S3["DB grows: scale PostgreSQL VMs vertically (RAM, NVMe)"] S3 --> S4["More storage: add Garage nodes per zone"] S4 --> S5["Many mailboxes: Kubernetes per provider, HPA"]
| Composant | Mise à l'échelle | Remarque |
|---|---|---|
| Nœuds / pods nextxch | horizontalement | Sans état ; davantage de nœuds par site ou davantage de pods (HPA). Aucune session persistante (sticky session) requise. |
| Primaire PostgreSQL | verticalement | Toutes les écritures vont vers un seul primaire. Plus de RAM et des disques plus rapides (IOPS) plutôt que davantage de nœuds ; valeur de départ de shared_buffers : 25 % de la RAM. nextxch n'utilise pas de réplicas en lecture. [57] |
| Stockage de blobs | horizontalement | Davantage de nœuds Garage par zone avec capacité (garage layout assign -c), les données sont rééquilibrées. La déduplication SHA-256 et la compression Zstd réduisent ce besoin. |
| SMTP | via le DNS | Davantage d'enregistrements MX de même préférence répartissent la charge ; la file d'attente sortante se répartit sur tous les nœuds via SKIP LOCKED. |
| IMAP | via DNS / LB | Les connexions IDLE de longue durée restent sur leur nœud ; les nouveaux messages provenant d'autres nœuds arrivent via LISTEN/NOTIFY. Après l'ajout d'un nœud, la charge ne se répartit qu'au fur et à mesure que les clients se reconnectent. [7] |
| HTTPS (Webmail, ActiveSync, JMAP) | via DNS / LB | Sessions HMAC sans état ; n'importe quelle requête peut atteindre n'importe quel nœud. |
11 · Coûts : où économiser, où ne pas le faire
Enregistrer
- Offres vCPU partagés pour nextxch et le témoin ; cœurs dédiés uniquement lorsque la charge CPU de la base de données l'exige.
- Engagement de 12 mois chez netcup (13 % de moins que le prix mensuel selon la page tarifaire). [22]
- Basculement DNS avec l'agent intégré (ou votre propre script) au lieu d'un répartiteur de charge payant.
- Compression Zstd (Storage:Compression) et déduplication SHA-256 pour les blobs.
Ne pas économiser sur
- RAM des nœuds PostgreSQL : tout ce qui n'est pas en cache coûte des E/S disque à chaque affichage de dossier.
- IOPS disque pour PostgreSQL (NVMe) ; les écritures WAL déterminent la latence de chaque remise.
- Le troisième site : sans lui, aucun basculement automatique sûr n'est possible.
- Sauvegardes testées vers une cible indépendante.
Prix enregistrés (HT par mois, état au 2 nov. 2026). netcup et STRATO affichent des prix TTC incluant 19 % de TVA, divisés ici par 1,19. Pour Hetzner, 0,50 € sont ajoutés pour l'IPv4 principale. À la date de vérification, Hetzner affichait toutes les offres cloud comme "currently unavailable" ; il n'a pas été possible de déterminer s'il s'agissait du stock ou de l'affichage. Le prix des volumes Hetzner n'a pas pu être relevé sur les pages officielles ; il est donc absent du calculateur. Les prix changent : vérifiez auprès du fournisseur avant de commander. [12][10][22][23][30][15][16]
| Fournisseur | Planifier | vCPU | RAM | Disque | € HT / mois |
|---|---|---|---|---|---|
| Hetzner Cloud | CX23 | 2 | 4 Go | 40 Go | €6.49 |
| Hetzner Cloud | CX33 | 4 | 8 Go | 80 Go | €9.49 |
| Hetzner Cloud | CX43 | 8 | 16 Go | 160 Go | €16.99 |
| Hetzner Cloud | CX53 | 16 | 32 Go | 320 Go | €30.49 |
| netcup VPS | VPS 500 G12.5 | 2 | 4 Go | 64 Go | €6.94 |
| netcup VPS | VPS 1000 G12.5 | 4 | 8 Go | 128 Go | €12.18 |
| netcup VPS | VPS 2000 G12.5 | 8 | 16 Go | 256 Go | €22.62 |
| netcup VPS | VPS 4000 G12.5 | 12 | 32 Go | 512 Go | €38.12 |
| netcup VPS | VPS 8000 G12.5 | 16 | 64 Go | 1 024 Go | €56.39 |
| netcup Root Server | RS 1000 G12.5 | 4 | 8 Go | 128 Go | €18.26 |
| netcup Root Server | RS 2000 G12.5 | 8 | 16 Go | 256 Go | €34.20 |
| netcup Root Server | RS 4000 G12.5 | 12 | 32 Go | 512 Go | €65.40 |
| netcup Root Server | RS 8000 G12.5 | 16 | 64 Go | 1 024 Go | €124.72 |
| netcup Root Server | RS 12000 G12.5 | 20 | 96 Go | 1536 Go | €182.26 |
| netcup Root Server | RS 16000 G12.5 | 24 | 128 Go | 2 048 Go | €243.24 |
| STRATO VPS | VPS S | 1 | 2 Go | 60 Go | €3.36 |
| STRATO VPS | VPS M | 2 | 4 Go | 120 Go | €8.40 |
| STRATO VPS | VPS L | 4 | 8 Go | 240 Go | €16.81 |
| STRATO VPS | VPS XL | 8 | 16 Go | 480 Go | €32.77 |
| STRATO VPS | VPS XXL | 12 | 24 Go | 720 Go | €47.06 |
| Hetzner Storage Box | BX11 | - | - | 1 To | €3.20 |
| Hetzner Storage Box | BX21 | - | - | 5 To | €10.90 |
| Hetzner Storage Box | BX31 | - | - | 10 To | €20.80 |
| Hetzner Storage Box | BX41 | - | - | 20 To | €40.60 |
| Hetzner Object Storage | prix de base incl. 1 To de stockage + 1 To de trafic sortant | - | - | 1 To | €6.49 |
12 · Calculateur : ressources et coûts
Fonctionne entièrement dans le navigateur, sans services externes. Les hypothèses sont indiquées ouvertement sous le résultat.
Hypothèses (non mesurées, à vérifier avec vos propres chiffres)
- Volume de courrier = boîtes aux lettres × taille moyenne × (1 + croissance)^années. Aucune déduction pour la déduplication ou la compression (estimation prudente).
- Taille PostgreSQL = 3 % du volume de courrier (métadonnées, indicateurs, index en texte intégral), au moins 2 Go. La valeur réelle est donnée par
SELECT pg_database_size('nextxch'). - RAM pour PostgreSQL = 25 % de la taille de la base, au moins 2 Go, arrondie à 2/4/8/16/32/64/96/128 Go. Disque pour PostgreSQL = 2 × taille de la base (données, WAL, réserve).
- Processus nextxch : 1 Go + 1 Go par tranche entamée de 1 000 boîtes aux lettres (la limite Kubernetes dans le dépôt est de 1 GiB par pod). etcd + Garage + OS : 1 Go. Témoin : 2 Go de RAM. Système d'exploitation : 20 Go de disque.
- Garage replication_factor 3 : chaque zone (site ou témoin) stocke l'intégralité du volume de courrier.
- Configuration L : au moins 2 pods par fournisseur, 1 pod pour 1 500 boîtes aux lettres, workers avec 8 Go ; la VM de données héberge PostgreSQL, etcd et la zone Garage.
- Besoin de sauvegarde = 3 × taille de la base (2 sauvegardes complètes + WAL) + volume de courrier ; cible : la plus petite Hetzner Storage Box adaptée.
- Prix de licence issus du catalogue de prix actuel de ce site (le prix du palier s'applique à toutes les boîtes aux lettres, prix annuel / 12). Cloudflare est indiqué en USD et n'est pas ajouté au total en euros.
13 · Ajouter un autre fournisseur (exemple STRATO)
- Avant de commander : le PTR peut-il être défini ? STRATO : DNS inverse pour les VPS dans l'espace client sous Domaines, un enregistrement A/AAAA doit exister au préalable, jusqu'à 24 h de délai. Port 25 : pour les serveurs root, STRATO documente le trafic sortant comme bloqué, avec déblocage via le support ; pour les VPS, aucune règle n'est documentée. STRATO ne propose pas d'IP de basculement. [31][32][30]
- Ajoutez le nouvel hôte à l'inventaire Ansible avec sa propre IP WireGuard et sa zone Garage, ajoutez le pair sur tous les sites, exécutez le playbook.
- etcd : ajoutez le membre avec
etcdctl member addet démarrez-le avec ETCD_INITIAL_CLUSTER_STATE=existing. Avec quatre sites de base de données, gardez un nombre impair de membres etcd. [48] - Patroni démarre en tant que réplica et réplique automatiquement. Garage :
garage layout assignavec la nouvelle zone,garage layout apply. - DNS : ajoutez l'IP au SPF, un nouveau MX de même préférence, le nœud à la liste de basculement DNS (agent ou script), un enregistrement TLSA pour le nouveau nom MX, le nom EHLO et le PTR du nouveau nœud, et étendez le certificat à ce nom. La politique MTA-STS intègre automatiquement le nouveau MX (liste MX automatique) ; publiez ensuite la nouvelle valeur TXT _mta-sts fournie par le conseiller DNS.
- Ajoutez le site à la matrice du pipeline et testez une fois une panne du nouveau site.
14 · Limites et lacunes (en toute honnêteté)
Constaté dans le code lors de la rédaction de ce guide. Quatre lacunes sont désormais comblées : MTA-STS avec plusieurs noms MX, nom EHLO par nœud, bucket Object Lock créé automatiquement et agent de basculement DNS intégré. Le guide contourne le reste comme décrit.
| Lacune | Impact | Solution de contournement |
|---|---|---|
| Agent de basculement DNS (résolu) | Intégré, à activer : contrôles de santé depuis chaque nœud, seul le leader du cluster modifie les enregistrements A/AAAA via Hetzner DNS ou Cloudflare. | Autres fournisseurs DNS (par ex. deSEC) : script sous verrou etcd (chapitre 04). |
| MTA-STS avec plusieurs MX (résolu) | La politique par domaine nomme tous les noms MX (MX DNS, noms du serveur et des nœuds, ou une liste propre avec caractères génériques) ; l'id de la politique suit la liste. | Après chaque changement de MX, publiez la valeur TXT _mta-sts fournie par le conseiller DNS. |
| Nom EHLO par nœud (résolu) | Smtp:HeloHostname ou un paramètre par nœud dans /admin/, avec un contrôle PTR par nœud ; le nom d'hôte du serveur reste commun à tout le cluster. | - |
| Une seule cible S3 | Pas de double écriture dans deux stockages S3 chez des fournisseurs différents ; les fonctionnalités Object Lock exigent un S3 avec Object Lock. | Garage sur trois sites plus copie rclone dans un bucket Object Lock. |
| Bucket Object Lock (fermé) | Lorsqu'un mode WORM est configuré, le serveur crée au démarrage un bucket manquant avec Object Lock. Un bucket existant sans Object Lock est signalé par un avertissement et une alerte, car Object Lock ne peut être activé qu'à la création. [18] | Vérifiable à tout moment avec « Check S3 bucket » dans l'archive GoBD. |
| Pas de sauvegarde intégrée | Le serveur ne comporte aucun service de sauvegarde pour la base de données et les blobs. | pgBackRest et rclone (chapitre 09). |
| Pas de réplicas en lecture | Toutes les requêtes vont au primaire ; les réplicas en lecture ne figurent dans le plan de développement que "sur demande". | Faites évoluer verticalement le primaire PostgreSQL. |
| Aucun benchmark de dimensionnement mesuré | Les tailles de RAM et de base de données du calculateur sont des hypothèses. | Remplacez par vos propres mesures. |
FAQ
nextxch peut-il fonctionner en actif-actif sur plusieurs fournisseurs ?
Oui. Avec Cluster:Enabled=true et PostgreSQL, un nombre quelconque de nœuds nextxch partagent leur état via LISTEN/NOTIFY, des tables UNLOGGED et des advisory locks. Tous les nœuds doivent atteindre le même primaire PostgreSQL et le même stockage de blobs S3 ; entre fournisseurs, cela passe par un réseau WireGuard.
Ai-je besoin d'un répartiteur de charge ou d'anycast ?
Non. Le courrier entrant n'a besoin que de plusieurs enregistrements MX de même préférence (RFC 5321, section 5.1). IMAP, la soumission et HTTPS ont besoin d'un nom avec un TTL court dont les enregistrements A sont gérés par une tâche de contrôle d'état. L'anycast/BGP est possible mais complexe.
De combien de sites ai-je besoin au minimum ?
Trois voix : deux sites complets plus un petit témoin (etcd, zone Garage, vérificateur de basculement), ou trois sites complets. Avec seulement deux sites, aucun côté ne peut déterminer avec certitude si l'autre est tombé ou si seule la liaison est coupée (split brain).
Des e-mails sont-ils perdus lorsqu'un fournisseur est hors ligne ?
Les expéditeurs continuent de réessayer pendant au moins 4 à 5 jours (RFC 5321, section 4.5.4.1). Avec Patroni synchronous_mode et Garage replication_factor 3, un message acquitté par 250 OK est déjà stocké sur un second site.
15 · Sources
Tous les faits externes ont été vérifiés le 2 nov. 2026 auprès des sources officielles suivantes. Les points qui n'ont pas pu être vérifiés sont signalés comme tels dans le texte.
- 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 limite de requêtes DNS, 5.6 ip4/ip6 : https://www.rfc-editor.org/rfc/rfc7208.html
- RFC 6376 (DKIM) : 3.1 sélecteurs : https://www.rfc-editor.org/rfc/rfc6376.html
- RFC 8461 (MTA-STS) : 3.2, 4.1 modèles mx : https://www.rfc-editor.org/rfc/rfc8461.html
- RFC 7672 (DANE pour SMTP) : 3.1 TLSA 3 1 1 : https://www.rfc-editor.org/rfc/rfc7672.html
- RFC 8314 (TLS implicite 465/993/995) : https://www.rfc-editor.org/rfc/rfc8314.html
- RFC 2177 (IMAP IDLE, renouvellement toutes les 29 minutes) : https://www.rfc-editor.org/rfc/rfc2177.html
- Google : consignes pour les expéditeurs d'e-mails (DNS direct et inverse) : https://support.google.com/a/answer/81126
- Hetzner Docs : Floating IPs : https://docs.hetzner.com/cloud/floating-ips/overview
- Hetzner Docs : tarifs IPv4 : https://docs.hetzner.com/general/others/ipv4-pricing
- Hetzner Docs : IP de basculement Robot : https://docs.hetzner.com/robot/dedicated-server/ip/failover
- Hetzner Cloud : offres à coût optimisé : https://www.hetzner.com/cloud/cost-optimized/
- Hetzner Docs : FAQ Cloud server (ports 25/465) : https://docs.hetzner.com/cloud/servers/faq/
- Hetzner Docs : enregistrements PTR : https://docs.hetzner.com/networking/dns/record-types/ptr-record/
- Hetzner Storage Box (prix, protocoles) : https://www.hetzner.com/storage/storage-box/
- Hetzner Object Storage (prix) : https://www.hetzner.com/storage/object-storage/
- Hetzner Docs : vue d'ensemble d'Object Storage : https://docs.hetzner.com/storage/object-storage/overview
- Hetzner Docs : Object Lock et conservation : https://docs.hetzner.com/storage/object-storage/howto-protect-objects/protect-object-lock-retention/
- Hetzner Docs : FAQ Load Balancer : https://docs.hetzner.com/cloud/load-balancers/faq
- Hetzner Docs : FAQ Networks : https://docs.hetzner.com/cloud/networks/faq
- Hetzner Docs : DNS (console et API) : https://docs.hetzner.com/networking/dns/overview/
- netcup : prix des VPS G12.5 : https://www.netcup.com/de/server/vps
- netcup : prix des Root Server G12.5 : https://www.netcup.com/de/server/root-server
- Centre d'aide netcup : adresses IP et basculement : https://www.netcup.com/en/helpcenter/documentation/server/ip
- netcup : extensions de serveur (prix des IP failover) : https://www.netcup.com/de/server/kvm-server-erweiterungen
- netcup Helpcenter : pare-feu (politique "netcup Mail block") : https://www.netcup.com/en/helpcenter/documentation/server/firewall
- Centre d'aide netcup : réseau / rDNS : https://www.netcup.com/en/helpcenter/documentation/server/network-server
- netcup : Storage Space (NFS) : https://www.netcup.com/en/server/storage-space
- Centre d'aide netcup : API DNS : https://www.netcup.com/en/helpcenter/documentation/domain/our-api
- STRATO : prix VPS Linux : https://www.strato.de/server/linux-vserver/
- FAQ STRATO : DNS inverse pour VPS : https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
- FAQ STRATO : pare-feu réseau des serveurs root (port 25) : https://www.strato.de/faq/server/wie-stelle-ich-die-netzwerk-firewall-bei-meinem-windows-root-server-ein/
- Cloudflare : prix du module Load Balancing : https://www.cloudflare.com/plans/network-cdn/
- Cloudflare Docs : limites du Load Balancing (intervalle des contrôles de santé) : https://developers.cloudflare.com/load-balancing/reference/limitations/
- Cloudflare Docs : enregistrements DNS Load Balancing (MX/SRV uniquement en Enterprise) : https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
- Cloudflare Docs : modes de proxy (DNS-only) : https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
- Cloudflare Docs : protocoles Spectrum par offre : https://developers.cloudflare.com/spectrum/protocols-per-plan/
- Cloudflare Docs : TTL DNS : https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/
- Cloudflare API : modifier un enregistrement DNS : https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
- deSEC : domaines et TTL minimal : https://desec.readthedocs.io/en/latest/dns/domains.html
- Akamai/Linode : basculement IP (BGP, même centre de données) : https://techdocs.akamai.com/cloud-computing/docs/configure-failover-on-a-compute-instance
- Akamai : tarifs NodeBalancer : https://www.akamai.com/cloud/pricing/nodebalancers
- Vultr : prérequis BGP : https://docs.vultr.com/configuring-bgp-on-vultr
- RIPE NCC : demander un numéro d'AS : https://www.ripe.net/manage-ips-and-asns/as-numbers/request-an-as-number/
- Proxmox VE : Cluster Manager (latence corosync, QDevice) : https://pve.proxmox.com/wiki/Cluster_Manager
- Proxmox Backup Server : datastores (y compris S3) : https://pbs.proxmox.com/docs/storage.html
- WireGuard : https://www.wireguard.com/
- k3s : HA avec etcd intégré : https://docs.k3s.io/datastore/ha-embedded
- k3s : cluster hybride ou multicloud distribué : https://docs.k3s.io/networking/distributed-multicloud
- etcd : réglage (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 : configuration dynamique (ttl, loop_wait, retry_timeout) : https://patroni.readthedocs.io/en/latest/dynamic_configuration.html
- Patroni : modes de réplication (synchronous_mode, strict) : https://patroni.readthedocs.io/en/latest/replication_modes.html
- Patroni : mode failsafe du DCS : https://patroni.readthedocs.io/en/latest/dcs_failsafe_mode.html
- PostgreSQL 18 : réplication synchrone : 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 (dépôts S3 et SFTP) : https://pgbackrest.org/
- CISA : Data Backup Options (3-2-1) : https://www.cisa.gov/sites/default/files/publications/data_backup_options.pdf
- Garage : page du projet (multi-datacenter, 200 ms / 50 Mbit/s) : https://garagehq.deuxfleurs.fr/
- Garage : configuration (replication_factor, quorums) : https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
- Garage : compatibilité S3 (pas d'Object Lock, pas de versionnage) : https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/
- Npgsql : basculement et répartition de charge (multi-hôte) : https://www.npgsql.org/doc/failover-and-load-balancing.html
- Mermaid 12.2.0 (MIT), intégré à ce site : https://www.npmjs.com/package/mermaid