Guide d'exploitation Hetzner · netcup · STRATO · état au 2 nov. 2026

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éCommentSource dans le code
Backplane de cluster sans RedisPostgreSQL 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 heartbeatsrc/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs
Tâches du leaderpg_try_advisory_lock : exactement un nœud renouvelle les certificats ACME, envoie le heartbeat de licence, synchronise LDAP et envoie les rappels de calendrierAcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs
File d'attente SMTP répartie sur les nœudsFOR UPDATE SKIP LOCKED : chaque nœud réserve des éléments de file d'attente différents, pas de double remisesrc/Nextxch.Storage/Postgres/PostgresMailStore.cs
Bases de données compatibles clusterPostgreSQL (par défaut pour les clusters) et SQL Server (sp_getapplock) ; SQLite en nœud unique uniquementsrc/Nextxch.Storage/StorageServiceCollectionExtensions.cs
Stockage de blobsFileSystem, 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 & DKIMLes 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 clientProtocole PROXY v1/v2 pour SMTP, IMAP, ManageSieve (Cluster:EnableProxyProtocol) et X-Forwarded-For pour HTTP depuis TrustedProxyCidrssrc/Nextxch.Core/Abstractions/IClusterCoordinator.cs
Artefacts de déploiementdocker-compose.yml, manifestes Kubernetes (3 réplicas, HPA de 2 à 10, PDB, sondes), chart Helm, deb/rpm, MSI Windowsdeploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/
LicenceCe 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_kvsrc/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).

La seule exigence stricte
Tous les nœuds ont besoin à tout moment d'exactement un primaire PostgreSQL accessible en écriture et d'un stockage de blobs partagé. Sans base de données joignable, /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 DNS pour deux sites (modèle)
; 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)
Nom EHLO et PTR
Le nom d'hôte du serveur (mail.example.com, pour les clients, Autodiscover et le certificat) s'applique à l'ensemble du cluster. Chaque nœud définit son propre nom EHLO/HELO : 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]
Nom EHLO par nœud
# 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
Politique MTA-STS avec tous les hôtes MX (servie par nextxch pour chaque domaine)
# 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.

OptionPortéeCoûtÉvaluation
Plusieurs MX de même préférencesur plusieurs fournisseurs0 €Obligatoire pour le courrier entrant, normalisé [1]
Tâche de contrôle d'état propre + API DNS (Cloudflare, Hetzner, deSEC, netcup)sur plusieurs fournisseurs0 €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/moisContrô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 Spectrumsur plusieurs fournisseursEnterprisePorts 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éseauIPv4 3,00 €, IPv6 1,00 €/moisUniquement au sein de Hetzner [9]
IP failover Hetzner (Robot, dédié)Centres de données Hetzner4,20 €/mois + 4,90 € de frais d'installationBascule via l'API Robot en 90 à 110 s, uniquement au sein de Hetzner [11]
IP de basculement netcupmême site netcup5,00 €/mois TTCRéacheminement dans le SCP à tout moment et gratuitement, uniquement au sein de netcup [24][25]
Partage d'IP Akamai/Linodeun 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 propreglobalé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]
Pourquoi un témoin est nécessaire
Avec deux sites, aucun ne peut déterminer si l'autre est tombé ou si seule la liaison est coupée. Si les deux se promouvaient, on obtiendrait deux bases de données divergentes (split brain). etcd décide à la majorité : deux membres sur trois suffisent. D'où : trois voix chez trois fournisseurs, la troisième pouvant être un petit témoin. [48][53]

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
Configuration d'architecture S

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]

Non testé et sans Object Lock
Garage ne prend en charge ni S3 Object Lock ni le versionnage. Les domaines avec conservation WORM (governance/compliance) amènent nextxch à envoyer des en-têtes de conservation et nécessitent donc un S3 avec Object Lock ; voir le chapitre 09 pour la copie GoBD. L'exploitation de nextxch sur Garage n'a pas fait l'objet de tests de charge pour ce guide : vérifiez avec des données de test avant la production. [62]

Étape par étape

  1. 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]
  2. Définir le PTR : IP A vers mx1.example.com (Hetzner Console), IP B vers mx2.example.com (netcup SCP). [14][27]
  3. Installation de base via cloud-init (Debian 13) : utilisateur de déploiement, Docker, WireGuard, pas de connexion par mot de passe.
  4. Maillage WireGuard 10.88.0.0/24 entre A, B, C ; n'ouvrez UDP 51820 que pour les IP des pairs. [47]
  5. etcd sur les trois (election timeout d'au moins 10 × RTT), puis Patroni + PostgreSQL 18 sur A et B. [50][53]
  6. Garage sur les trois, appliquez un layout à trois zones, créez le bucket et la clé. [61]
  7. 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.
  8. 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.
  9. 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-init (user data)
#cloud-config
# Hetzner Cloud / netcup: paste as user data when creating the VM.
users:
  - name: deploy
    groups: [sudo, docker]
    shell: /bin/bash
    sudo: "ALL=(ALL) NOPASSWD:ALL"
    ssh_authorized_keys:
      - ssh-ed25519 AAAA... ci-deploy
package_update: true
packages: [wireguard, curl, jq, docker.io, docker-compose-v2, unattended-upgrades]
runcmd:
  - [ systemctl, enable, --now, docker ]
  - [ sed, -i, "s/^#\\?PasswordAuthentication.*/PasswordAuthentication no/", /etc/ssh/sshd_config ]
  - [ systemctl, restart, ssh ]
WireGuard : /etc/wireguard/wg0.conf
# /etc/wireguard/wg0.conf on site A (Hetzner). Same pattern on B and C.
# Full mesh: every site has one [Peer] block per other site.
[Interface]
Address    = 10.88.0.1/24
ListenPort = 51820
PrivateKey = <private-key-of-site-A>   # wg genkey, never commit it

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

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

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

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

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

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

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

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

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

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

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

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

volumes:
  nextxch-data:

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é.

Agent de basculement DNS intégré
# 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.
Pourquoi un nœud isolé ne modifie pas le DNS
Le verrou leader n'existe que via une connexion au primaire PostgreSQL. Avec 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]

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

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

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

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

  have=$(curl -fsS "${AUTH[@]}" "${API}?type=A&name=${NAME}" | jq -r '.result[] | "\(.id) \(.content)"')
  for ip in "${want[@]}"; do   # add missing healthy IPs
    grep -q " ${ip}$" <<<"$have" || curl -fsS "${AUTH[@]}" -X POST "$API" \
      --data "{\"type\":\"A\",\"name\":\"${NAME}\",\"content\":\"${ip}\",\"ttl\":${TTL},\"proxied\":false}" >/dev/null
  done
  while read -r id ip; do      # remove IPs that are no longer healthy
    [ -z "${id:-}" ] && continue
    printf '%s\n' "${want[@]}" | grep -qx "$ip" || curl -fsS "${AUTH[@]}" -X DELETE "${API}/${id}" >/dev/null
  done <<<"$have"
  sleep 20
done
Unité systemd
# /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"]
Configuration d'architecture M
Proxmox : pas de cluster entre fournisseurs
Selon Proxmox, corosync nécessite des latences inférieures à 5 ms (niveau LAN) ; au-delà d'environ 10 ms et avec plus de trois nœuds, il est peu probable qu'il fonctionne. Un cluster Proxmox VE unique couvrant Hetzner, netcup et STRATO n'est donc pas recommandé. Le bon modèle : un hôte ou cluster Proxmox indépendant par fournisseur, la haute disponibilité étant assurée par l'application (Patroni, Garage, cluster nextxch). Un QDevice (corosync-qnetd) est destiné aux clusters à deux nœuds sur un même site et n'est pas soumis à la limite de latence. Le Proxmox Datacenter Manager (1.0 depuis le 2025-12-04) gère plusieurs clusters ensemble. [45]

Étape par étape

  1. Trois VM (ou invités Proxmox) chez trois fournisseurs, même version de Debian, PTR vers mx1/mx2/mx3 respectivement.
  2. Inventaire Ansible avec trois hôtes, un seul playbook pour tous (WireGuard, etcd, Patroni, Garage, nextxch).
  3. 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]
  4. 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.
  5. 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]
Ansible : inventory/ha.yml
# inventory/ha.yml: three sites, one VM (or Proxmox guest) per site
all:
  vars:
    ansible_user: deploy
    wg_network: 10.88.0.0/24
    pg_major: 18
  children:
    nextxch_sites:
      hosts:
        hetzner-a: { ansible_host: 192.0.2.10,    wg_ip: 10.88.0.1, garage_zone: hetzner }
        netcup-b:  { ansible_host: 198.51.100.20, wg_ip: 10.88.0.2, garage_zone: netcup }
        third-c:   { ansible_host: 203.0.113.30,  wg_ip: 10.88.0.3, garage_zone: third }
Ansible : site.yml
# site.yml: idempotent base for every site (same playbook for Hetzner, netcup, Strato ...)
- hosts: nextxch_sites
  become: true
  tasks:
    - name: Base packages (Debian 13)
      ansible.builtin.apt:
        name: [wireguard, etcd-server, etcd-client, patroni, pgbackrest, docker.io, docker-compose-v2, jq, curl]
        update_cache: true
    # PostgreSQL 18 itself comes from the PGDG apt repository (apt.postgresql.org),
    # Garage as the static binary or the dxflrs/garage image.

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

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

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

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

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

06 · 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
Configuration d'architecture L

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

  1. Construisez la couche de données comme dans la configuration M (trois VM avec Patroni, etcd, Garage).
  2. Un cluster k3s ou Talos par fournisseur ; liaison réseau à la couche de données via WireGuard (ou KubeSpan).
  3. 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ôt Cluster__EnableProxyProtocol=true. Le Hetzner Cloud Load Balancer prend en charge le protocole PROXY et tout service TCP. [19]
  4. 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).
  5. 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.
Helm : k8s/values-hetzner.yaml
# values-hetzner.yaml (one file per provider cluster; netcup/strato analogue)
# Helm chart: deploy/helm/nextxch in the nextxch repository.
replicaCount: 3
mail:
  hostname: mail.example.com
  defaultDomain: example.com
postgres:
  # Patroni data layer outside Kubernetes, reached through WireGuard / KubeSpan
  connectionString: "Host=10.88.0.1,10.88.0.2,10.88.0.3;Database=nextxch;Username=nextxch;Password=<from secret>;Target Session Attributes=primary"
s3:
  endpointUrl: "http://garage.nextxch-data.svc:3900"   # Garage zone of this provider
  bucketName: "nextxch"

07 · 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"]
Pipeline de déploiement
GitHub Actions : sites Compose
# .github/workflows/deploy.yml: rolling update of the compose sites, one site at a time.
name: deploy-nextxch
on:
  push:
    branches: [main]
    paths: ["sites/**", "compose/**"]
  workflow_dispatch:

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

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

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

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

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

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

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

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

jobs:
  helm:
    runs-on: ubuntu-latest
    strategy:
      max-parallel: 1
      matrix:
        cluster: [hetzner, netcup, strato]
    environment: k8s-${{ matrix.cluster }}
    steps:
      - uses: actions/checkout@v4
      - uses: azure/setup-helm@v4
      - name: Kubeconfig
        run: |
          mkdir -p ~/.kube
          echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
          chmod 600 ~/.kube/config
      - name: Helm upgrade (waits for readiness probes on /health/ready)
        run: |
          helm upgrade --install nextxch ./deploy/helm/nextxch \
            --namespace nextxch --create-namespace \
            -f k8s/values-common.yaml -f k8s/values-${{ matrix.cluster }}.yaml \
            --set image.tag=${{ vars.NEXTXCH_TAG }} \
            --wait --timeout 10m --atomic
La tâche de basculement n'a pas sa place dans la CI
Les exécutions CI planifiées ne sont ni précises à la seconde ni garanties. Le basculement DNS s'exécute donc sur les sites eux-mêmes : en tant qu'agent intégré à nextxch ou en tant que service systemd avec le script (chapitre 04), que le pipeline se contente de déployer.
La configuration de la messagerie aussi depuis Git
Domaines, boîtes aux lettres, listes, règles, SMTP et les autres paramètres résident également dans le dépôt sous forme de YAML : 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
Déroulement en cas de panne du site A
DuréeCe qui se passeCe que les utilisateurs remarquent
0 sA 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 environLa 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 sPatroni 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 sLe 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 minLes 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
Retour du site 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
Flux de sauvegarde
  • 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.
pgBackRest : /etc/pgbackrest/pgbackrest.conf
# /etc/pgbackrest/pgbackrest.conf on every PostgreSQL site.
# Repo 1: Hetzner Storage Box over SFTP (pgBackRest 2.46+), repo 2: S3 at another provider.
[global]
repo1-type=sftp
repo1-path=/pgbackrest
repo1-sftp-host=uXXXXXX.your-storagebox.de
repo1-sftp-host-port=23
repo1-sftp-host-user=uXXXXXX
repo1-sftp-private-key-file=/var/lib/postgresql/.ssh/id_ed25519
repo1-sftp-host-key-hash-type=sha256
repo1-retention-full=2
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<long random secret, stored outside the repo>

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

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

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

# Weekly full, daily differential (cron on every node; pgBackRest runs only where it can,
# use --repo and the "backup-standby" option to take backups from a replica).
#   0 2 * * 0  pgbackrest --stanza=nextxch --type=full backup
#   0 2 * * 1-6 pgbackrest --stanza=nextxch --type=diff backup
rclone : copie des blobs et bucket GoBD
# 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"]
Évolution possible
ComposantMise à l'échelleRemarque
Nœuds / pods nextxchhorizontalementSans état ; davantage de nœuds par site ou davantage de pods (HPA). Aucune session persistante (sticky session) requise.
Primaire PostgreSQLverticalementToutes 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 blobshorizontalementDavantage 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.
SMTPvia le DNSDavantage 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.
IMAPvia DNS / LBLes 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 / LBSessions 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]

FournisseurPlanifiervCPURAMDisque€ HT / mois
Hetzner CloudCX2324 Go40 Go€6.49
Hetzner CloudCX3348 Go80 Go€9.49
Hetzner CloudCX43816 Go160 Go€16.99
Hetzner CloudCX531632 Go320 Go€30.49
netcup VPSVPS 500 G12.524 Go64 Go€6.94
netcup VPSVPS 1000 G12.548 Go128 Go€12.18
netcup VPSVPS 2000 G12.5816 Go256 Go€22.62
netcup VPSVPS 4000 G12.51232 Go512 Go€38.12
netcup VPSVPS 8000 G12.51664 Go1 024 Go€56.39
netcup Root ServerRS 1000 G12.548 Go128 Go€18.26
netcup Root ServerRS 2000 G12.5816 Go256 Go€34.20
netcup Root ServerRS 4000 G12.51232 Go512 Go€65.40
netcup Root ServerRS 8000 G12.51664 Go1 024 Go€124.72
netcup Root ServerRS 12000 G12.52096 Go1536 Go€182.26
netcup Root ServerRS 16000 G12.524128 Go2 048 Go€243.24
STRATO VPSVPS S12 Go60 Go€3.36
STRATO VPSVPS M24 Go120 Go€8.40
STRATO VPSVPS L48 Go240 Go€16.81
STRATO VPSVPS XL816 Go480 Go€32.77
STRATO VPSVPS XXL1224 Go720 Go€47.06
Hetzner Storage BoxBX11--1 To€3.20
Hetzner Storage BoxBX21--5 To€10.90
Hetzner Storage BoxBX31--10 To€20.80
Hetzner Storage BoxBX41--20 To€40.60
Hetzner Object Storageprix 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)

  1. 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]
  2. 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.
  3. etcd : ajoutez le membre avec etcdctl member add et 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]
  4. Patroni démarre en tant que réplica et réplique automatiquement. Garage : garage layout assign avec la nouvelle zone, garage layout apply.
  5. 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.
  6. 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.

LacuneImpactSolution 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 S3Pas 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éeLe 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 lectureToutes 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.

  1. RFC 5321 (SMTP) : 4.1.4 EHLO, 4.5.4.1 retry, 5.1 MX : https://www.rfc-editor.org/rfc/rfc5321.html
  2. RFC 7208 (SPF) : 4.6.4 limite de requêtes DNS, 5.6 ip4/ip6 : https://www.rfc-editor.org/rfc/rfc7208.html
  3. RFC 6376 (DKIM) : 3.1 sélecteurs : https://www.rfc-editor.org/rfc/rfc6376.html
  4. RFC 8461 (MTA-STS) : 3.2, 4.1 modèles mx : https://www.rfc-editor.org/rfc/rfc8461.html
  5. RFC 7672 (DANE pour SMTP) : 3.1 TLSA 3 1 1 : https://www.rfc-editor.org/rfc/rfc7672.html
  6. RFC 8314 (TLS implicite 465/993/995) : https://www.rfc-editor.org/rfc/rfc8314.html
  7. RFC 2177 (IMAP IDLE, renouvellement toutes les 29 minutes) : https://www.rfc-editor.org/rfc/rfc2177.html
  8. Google : consignes pour les expéditeurs d'e-mails (DNS direct et inverse) : https://support.google.com/a/answer/81126
  9. Hetzner Docs : Floating IPs : https://docs.hetzner.com/cloud/floating-ips/overview
  10. Hetzner Docs : tarifs IPv4 : https://docs.hetzner.com/general/others/ipv4-pricing
  11. Hetzner Docs : IP de basculement Robot : https://docs.hetzner.com/robot/dedicated-server/ip/failover
  12. Hetzner Cloud : offres à coût optimisé : https://www.hetzner.com/cloud/cost-optimized/
  13. Hetzner Docs : FAQ Cloud server (ports 25/465) : https://docs.hetzner.com/cloud/servers/faq/
  14. Hetzner Docs : enregistrements PTR : https://docs.hetzner.com/networking/dns/record-types/ptr-record/
  15. Hetzner Storage Box (prix, protocoles) : https://www.hetzner.com/storage/storage-box/
  16. Hetzner Object Storage (prix) : https://www.hetzner.com/storage/object-storage/
  17. Hetzner Docs : vue d'ensemble d'Object Storage : https://docs.hetzner.com/storage/object-storage/overview
  18. Hetzner Docs : Object Lock et conservation : https://docs.hetzner.com/storage/object-storage/howto-protect-objects/protect-object-lock-retention/
  19. Hetzner Docs : FAQ Load Balancer : https://docs.hetzner.com/cloud/load-balancers/faq
  20. Hetzner Docs : FAQ Networks : https://docs.hetzner.com/cloud/networks/faq
  21. Hetzner Docs : DNS (console et API) : https://docs.hetzner.com/networking/dns/overview/
  22. netcup : prix des VPS G12.5 : https://www.netcup.com/de/server/vps
  23. netcup : prix des Root Server G12.5 : https://www.netcup.com/de/server/root-server
  24. Centre d'aide netcup : adresses IP et basculement : https://www.netcup.com/en/helpcenter/documentation/server/ip
  25. netcup : extensions de serveur (prix des IP failover) : https://www.netcup.com/de/server/kvm-server-erweiterungen
  26. netcup Helpcenter : pare-feu (politique "netcup Mail block") : https://www.netcup.com/en/helpcenter/documentation/server/firewall
  27. Centre d'aide netcup : réseau / rDNS : https://www.netcup.com/en/helpcenter/documentation/server/network-server
  28. netcup : Storage Space (NFS) : https://www.netcup.com/en/server/storage-space
  29. Centre d'aide netcup : API DNS : https://www.netcup.com/en/helpcenter/documentation/domain/our-api
  30. STRATO : prix VPS Linux : https://www.strato.de/server/linux-vserver/
  31. FAQ STRATO : DNS inverse pour VPS : https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
  32. 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/
  33. Cloudflare : prix du module Load Balancing : https://www.cloudflare.com/plans/network-cdn/
  34. Cloudflare Docs : limites du Load Balancing (intervalle des contrôles de santé) : https://developers.cloudflare.com/load-balancing/reference/limitations/
  35. Cloudflare Docs : enregistrements DNS Load Balancing (MX/SRV uniquement en Enterprise) : https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
  36. Cloudflare Docs : modes de proxy (DNS-only) : https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
  37. Cloudflare Docs : protocoles Spectrum par offre : https://developers.cloudflare.com/spectrum/protocols-per-plan/
  38. Cloudflare Docs : TTL DNS : https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/
  39. Cloudflare API : modifier un enregistrement DNS : https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
  40. deSEC : domaines et TTL minimal : https://desec.readthedocs.io/en/latest/dns/domains.html
  41. Akamai/Linode : basculement IP (BGP, même centre de données) : https://techdocs.akamai.com/cloud-computing/docs/configure-failover-on-a-compute-instance
  42. Akamai : tarifs NodeBalancer : https://www.akamai.com/cloud/pricing/nodebalancers
  43. Vultr : prérequis BGP : https://docs.vultr.com/configuring-bgp-on-vultr
  44. RIPE NCC : demander un numéro d'AS : https://www.ripe.net/manage-ips-and-asns/as-numbers/request-an-as-number/
  45. Proxmox VE : Cluster Manager (latence corosync, QDevice) : https://pve.proxmox.com/wiki/Cluster_Manager
  46. Proxmox Backup Server : datastores (y compris S3) : https://pbs.proxmox.com/docs/storage.html
  47. WireGuard : https://www.wireguard.com/
  48. k3s : HA avec etcd intégré : https://docs.k3s.io/datastore/ha-embedded
  49. k3s : cluster hybride ou multicloud distribué : https://docs.k3s.io/networking/distributed-multicloud
  50. etcd : réglage (heartbeat, election timeout) : https://etcd.io/docs/v3.5/tuning/
  51. Talos Linux : KubeSpan : https://docs.siderolabs.com/talos/v1.11/networking/kubespan
  52. CloudNativePG : replica clusters : https://cloudnative-pg.io/docs/current/replica_cluster
  53. Patroni : configuration dynamique (ttl, loop_wait, retry_timeout) : https://patroni.readthedocs.io/en/latest/dynamic_configuration.html
  54. Patroni : modes de réplication (synchronous_mode, strict) : https://patroni.readthedocs.io/en/latest/replication_modes.html
  55. Patroni : mode failsafe du DCS : https://patroni.readthedocs.io/en/latest/dcs_failsafe_mode.html
  56. PostgreSQL 18 : réplication synchrone : https://www.postgresql.org/docs/current/warm-standby.html#SYNCHRONOUS-REPLICATION
  57. PostgreSQL 18 : shared_buffers : https://www.postgresql.org/docs/current/runtime-config-resource.html
  58. pgBackRest (dépôts S3 et SFTP) : https://pgbackrest.org/
  59. CISA : Data Backup Options (3-2-1) : https://www.cisa.gov/sites/default/files/publications/data_backup_options.pdf
  60. Garage : page du projet (multi-datacenter, 200 ms / 50 Mbit/s) : https://garagehq.deuxfleurs.fr/
  61. Garage : configuration (replication_factor, quorums) : https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
  62. Garage : compatibilité S3 (pas d'Object Lock, pas de versionnage) : https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/
  63. Npgsql : basculement et répartition de charge (multi-hôte) : https://www.npgsql.org/doc/failover-and-load-balancing.html
  64. Mermaid 12.2.0 (MIT), intégré à ce site : https://www.npmjs.com/package/mermaid