Guida operativa Hetzner · netcup · STRATO · aggiornato al 2 nov 2026

nextxch ad alta disponibilità su più provider

Quando un intero provider va offline, la posta deve continuare a fluire: senza costoso anycast, senza load balancer enterprise, distribuibile da Git. Questa guida mostra tre setup su server economici presso Hetzner, netcup e STRATO, spiega secondo per secondo cosa accade durante un guasto e calcola risorse e costi. Ogni affermazione sui provider rimanda a una fonte con la data di verifica, ogni affermazione su nextxch indica il file sorgente nel codice.

01 · Cosa offre nextxch per l'alta disponibilità (verificato nel codice)

nextxch è in grado di operare in active-active: ogni nodo accetta SMTP, IMAP, JMAP, ActiveSync e HTTPS, non esistono nodi passivi né sticky session. PostgreSQL contiene lo stato condiviso, i messaggi stessi sono archiviati nel blob store come oggetti .eml indirizzati tramite SHA-256.

FunzionalitàComeSorgente nel codice
Backplane del cluster senza RedisPostgreSQL LISTEN/NOTIFY (canale nextxch_cluster_events) per eventi come nuova posta, flag, expunge, coda; tabelle UNLOGGED cluster_ephemeral e cluster_nodes; heartbeat ogni 15 s, nodi rimossi dopo 60 s senza heartbeatsrc/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs
Attività del leaderpg_try_advisory_lock: esattamente un nodo rinnova i certificati ACME, invia l'heartbeat della licenza, sincronizza LDAP, invia i promemoria del calendarioAcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs
Coda SMTP tra i nodiFOR UPDATE SKIP LOCKED: ogni nodo prende in carico elementi diversi della coda, nessun doppio recapitosrc/Nextxch.Storage/Postgres/PostgresMailStore.cs
Database con supporto clusterPostgreSQL (predefinito per i cluster) e SQL Server (sp_getapplock); SQLite solo a nodo singolosrc/Nextxch.Storage/StorageServiceCollectionExtensions.cs
Archivio blobFileSystem, S3 (client SigV4 proprio con Content-MD5) o ibrido (cache NVMe locale + S3). Modalità Object Lock Normal, Governance, Compliance e legal holdsrc/Nextxch.Storage/Blobs/S3BlobStore.cs, HybridBlobStore.cs
Certificati & DKIMCertificati ACME e chiavi DKIM risiedono nel database; le challenge HTTP-01 sono condivise nel cluster, quindi qualsiasi nodo può rispondere; la chiave leaf viene mantenuta al rinnovo (DANE 3 1 1 resta valido)src/Nextxch.Security/Tls/AcmeCertificateManager.cs
Endpoint di stato/health/live (senza DB), /health/ready (DB raggiungibile, altrimenti 503), /health (panoramica incl. peer del cluster)src/Nextxch.Server/Program.cs
IP reale del clientProtocollo PROXY v1/v2 per SMTP, IMAP, ManageSieve (Cluster:EnableProxyProtocol) e X-Forwarded-For per HTTP da TrustedProxyCidrssrc/Nextxch.Core/Abstractions/IClusterCoordinator.cs
Artefatti di deploymentdocker-compose.yml, manifest Kubernetes (3 repliche, HPA da 2 a 10, PDB, probe), chart Helm, deb/rpm, Windows MSIdeploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/
LicenzaSi contano le caselle di posta attive, non i nodi o i pod; lo stato della licenza è condiviso tramite cluster_kvsrc/Nextxch.Server/Services/ClusterLicenseService.cs, Prezzi

Porte per nodo: 25, 465, 587 (SMTP), 143, 993 (IMAP), 4190 (ManageSieve), 80/443 (HTTPS, ACME, webmail, ActiveSync, JMAP). POP3 110/995 è disattivato per impostazione predefinita (Pop3:Enabled=false in Pop3Settings.cs).

L'unico requisito vincolante
Tutti i nodi necessitano in ogni momento esattamente di un primario PostgreSQL scrivibile e di un blob store condiviso. Senza un database raggiungibile, /health/ready restituisce 503 e il nodo non può verificare i destinatari. L'alta disponibilità tra provider significa quindi replicare PostgreSQL e il blob store tra i siti e commutare automaticamente. È ciò che costruiscono le tre configurazioni.

02 · Regole specifiche della posta che semplificano l'alta disponibilità

In entrata: l'MX ha il failover integrato

I mittenti ordinano i record MX per preferenza e devono provare ogni indirizzo; a parità di preferenza devono scegliere in ordine casuale. [1] Se una destinazione non è raggiungibile, l'intervallo tra i tentativi dovrebbe essere di almeno 30 minuti e il tempo di rinuncia di almeno 4-5 giorni. [1]

In uscita: SPF e PTR per IP

SPF deve elencare ogni IP in uscita di ogni sito; ip4/ip6 non rientrano nel limite di 10 lookup DNS. [2] Gmail richiede un DNS diretto e inverso valido; il PTR va impostato separatamente presso ciascun provider. [8][14][27][31]

DKIM, MTA-STS, DANE

Le chiavi DKIM risiedono nel database del cluster, un unico set DNS serve tutti i nodi; i selettori consentono più chiavi in parallelo. [3] La policy MTA-STS deve indicare ogni host MX; i wildcard coprono solo l'etichetta più a sinistra. [4] DANE richiede un record TLSA per ogni host MX (si consiglia 3 1 1) e una zona firmata con DNSSEC. [5]

Client: IMAP, submission, HTTPS

I client di posta non hanno un failover in stile MX. Si connettono a un unico nome (qui mail.example.com) i cui record A sono gestiti da un job di health check. Le connessioni IDLE vengono riemesse almeno ogni 29 minuti e finiscono quindi su un nodo integro. [7][6]

Zona DNS per due siti (modello)
; 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)
Nome EHLO e PTR
Il nome host del server (mail.example.com, per client, autodiscover e certificato) vale per l'intero cluster. Ogni nodo imposta il proprio nome EHLO/HELO: Smtp:HeloHostname nella configurazione o in /admin/ alla voce "Hostname, IPv6 & Ports" (nome EHLO per nodo, con controllo PTR per nodo). Viene usato per il banner 220, la risposta EHLO e l'EHLO in uscita. Far puntare il PTR di ciascun IP al rispettivo nome fisso mx1/mx2; il DNS advisor mostra un record SPF HELO per nodo. In ogni caso la RFC 5321, sezione 4.1.4, vieta di rifiutare un messaggio solo a causa di un nome EHLO non corrispondente. [1][8]
Nome EHLO per nodo
# 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
Policy MTA-STS con tutti gli host MX (servita da nextxch per dominio)
# 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 · Come vengono reindirizzati i client: opzioni a confronto

Gli IP di failover dei provider aiutano solo all'interno di un singolo provider. Per il guasto di un intero provider, il DNS resta lo strumento semplice ed economico.

OpzioneAmbitoCostiValutazione
Più MX con la stessa prioritàsu più provider0 €Obbligatorio per la posta in entrata, standardizzato [1]
Job di health check proprio + API DNS (Cloudflare, Hetzner, deSEC, netcup)su più provider0 €Consigliato. Cloudflare: TTL da 60 s (Enterprise 30 s), auto = 300 s. Hetzner DNS: gratuito, TTL da 60 s, API nella Hetzner Console. deSEC: TTL minimo per dominio, esempio 3600 s, inferiore solo su richiesta. netcup: esiste la CCP DNS API, TTL minimo non documentato. [38][39][21][40][29]
Cloudflare Load Balancing (solo DNS)su più providerda 5 USD/meseHealth check (incl. TCP, SMTP) ogni 15 s al di fuori di Enterprise; per A/AAAA/CNAME, MX/SRV solo Enterprise. Sostituisce il job proprio per mail.example.com. [33][34][35][36]
Cloudflare Spectrumsu più providerEnterprisePorte TCP arbitrarie (25, 465, 587, 993) solo come componente aggiuntivo Enterprise; Pro/Business solo SSH, Minecraft, RDP. [37]
Hetzner Floating IP (Cloud)un progetto, una zona di reteIPv4 3,00 €, IPv6 1,00 €/meseSolo all'interno di Hetzner [9]
IP di failover Hetzner (Robot, dedicato)Data center Hetzner4,20 €/mese + 4,90 € di attivazioneCommutazione tramite Robot API in 90-110 s, solo all'interno di Hetzner [11]
IP di failover netcupstessa sede netcup5,00 €/mese IVA inclusaReinstradamento nel SCP in qualsiasi momento e gratuito, solo all'interno di netcup [24][25]
Condivisione IP Akamai/Linodeun data center, due Linodeda 5 USD (Nanode)Basato su BGP (lelastic), solo nello stesso data center; NodeBalancer Basic 10 USD, TCP/HTTP [41][42]
Anycast / BGP proprioglobalealtoASN proprio (contratto RIPE tramite membership o LIR sponsor) più spazio di indirizzi proprio (presso Vultr almeno /24 IPv4 o /48 IPv6). Sproporzionato per la maggior parte delle installazioni nextxch. [43][44]
Perché serve un witness
Con due siti nessuno dei due può stabilire se l'altro si è guastato o se si è interrotto solo il collegamento. Se entrambi si promuovessero, ne risulterebbero due database divergenti (split brain). etcd decide a maggioranza: bastano due membri su tre. Quindi: tre voti presso tre provider, dove il terzo può essere un piccolo witness. [48][53]

04 · Setup S: due siti più witness

Per piccole e medie imprese. Il sito A (ad es. Hetzner Cloud) e il sito B (ad es. netcup VPS) eseguono ciascuno nextxch, PostgreSQL sotto Patroni, un membro etcd e una zona Garage. Un piccolo witness C presso un terzo provider (ad es. STRATO) ospita il terzo membro etcd, la terza copia Garage e il checker del failover DNS. Tutti e tre sono collegati in mesh tramite 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
Architettura setup S

Perché Garage come blob store?

Un bucket S3 presso un unico provider sparirebbe insieme a quel provider, e nextxch attualmente può scrivere su un solo target S3. Garage è uno store compatibile S3 (AGPL-3.0) progettato per più data center collegati via internet (requisito del progetto: latenza massima 200 ms, almeno 50 Mbit/s). Con replication_factor = 3 c'è una copia per zona; letture e scritture proseguono finché si guasta una sola zona (quorum di scrittura 2). Con due sole zone, la perdita di una zona lascerebbe lo store in sola lettura, motivo per cui anche il witness conserva una copia completa. [60][61]

Non testato e senza Object Lock
Garage non supporta né S3 Object Lock né il versioning. I domini con conservazione WORM (governance/compliance) fanno sì che nextxch invii header di conservazione e richiedono quindi un S3 con Object Lock; per la copia GoBD vedere il capitolo 09. L'esecuzione di nextxch su Garage non è stata sottoposta a test di carico per questa guida: verificare con dati di prova prima della produzione. [62]

Passo dopo passo

  1. Ordinare i server: A = Hetzner Cloud (le porte 25/465 sono bloccate per impostazione predefinita; richiedere lo sblocco dopo un mese e una prima fattura pagata, la 587 è aperta), B = netcup VPS (rimuovere la policy firewall "netcup Mail block" nel SCP), C = STRATO VPS come witness (il witness non necessita della porta 25). [13][26]
  2. Impostare il PTR: IP A su mx1.example.com (Hetzner Console), IP B su mx2.example.com (netcup SCP). [14][27]
  3. Installazione di base tramite cloud-init (Debian 13): utente di deploy, Docker, WireGuard, nessun accesso con password.
  4. Mesh WireGuard 10.88.0.0/24 tra A, B, C; aprire UDP 51820 solo per gli IP dei peer. [47]
  5. etcd su tutti e tre (election timeout almeno 10 × RTT), poi Patroni + PostgreSQL 18 su A e B. [50][53]
  6. Garage su tutti e tre, applicare un layout con tre zone, creare bucket e chiave. [61]
  7. nextxch tramite Docker Compose su A e B (Cluster:Enabled=true, stringa di connessione multi-host, S3 che punta al Garage locale). In /admin/ impostare il nome host mail.example.com ed emettere il certificato ACME per mail, mx1, mx2.
  8. DNS come nel capitolo 02, verificare l'elenco MX di MTA-STS in /admin/, attivare il failover DNS in /admin/ sotto Domini (agisce solo il leader del cluster) oppure, in alternativa, lo script sotto un lock etcd.
  9. Backup come nel capitolo 09 e testare un ripristino. Poi simulare un guasto: arrestare bruscamente il sito A e seguire il capitolo 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 per sito
# 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 stringa di connessione indica entrambi gli host PostgreSQL con Target Session Attributes=primary. nextxch crea le proprie connessioni tramite NpgsqlDataSource.Create (PostgresMailStore.cs); con più host Npgsql costruisce automaticamente una data source multi-host e si connette solo al primario corrente, ricontrollando lo stato ogni Host Recheck Seconds (predefinito 10 s). Dopo un failover di Patroni i nodi si riconnettono senza alcuna modifica alla configurazione; la connessione LISTEN del cluster riprova ogni 2 s (PostgresClusterCoordinator.cs). [63]

Failover DNS automatico per mail.example.com

nextxch include un agente di failover DNS (opt-in, disattivato per impostazione predefinita; /admin/ in Domini, scheda "DNS failover"). Ogni nodo controlla ogni 30 s, tramite l'IP pubblico, il banner SMTP sulla porta 25 e HTTPS /health di ogni nodo. Interviene solo il leader del cluster: detiene un advisory lock PostgreSQL, combina a maggioranza la visione di tutti i nodi e scrive i record A/AAAA dei nomi configurati tramite Hetzner DNS (Cloud API) o la Cloudflare API. Isteresi: dopo N cicli falliti un nodo esce dal DNS, dopo M cicli positivi vi rientra; l'ultimo nodo non viene mai rimosso. Ogni modifica compare nel registro eventi, la regola di allerta "DNS failover: node taken out" notifica tramite gli alert operativi. Il token API è crittografato con il keyring del server e non viene mai più mostrato.

Agente di failover DNS integrato
# 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.
Perché un nodo isolato non modifica il DNS
Il leader lock esiste solo tramite una connessione al primario PostgreSQL. Con Target Session Attributes=primary nella stringa di connessione (capitolo 04 sopra), un lato isolato non raggiunge alcun primario, non ottiene il lock e resta passivo. Il voto a maggioranza su tutti gli osservatori impedisce che un singolo nodo con una visione errata rimuova un nodo sano.

Alternativa: script sotto un lock etcd

Per i provider DNS senza connettore integrato (ad es. deSEC), o quando deve decidere etcd, lo script resta un'alternativa equivalente. Non eseguire agente e script per gli stessi nomi contemporaneamente. Lo script controlla ogni 20 s /health/ready di ogni nodo tramite il suo IP pubblico e allinea i record A tramite la Cloudflare API (PATCH/POST/DELETE su dns_records). Viene eseguito su ogni sito sotto etcdctl lock: agisce una sola copia, e solo sul lato con la maggioranza etcd. Un sito isolato non può quindi più modificare il DNS. Per Hetzner DNS o deSEC cambiano solo le tre chiamate curl. [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 · Setup M: tre siti completi (Proxmox o semplici VM)

Come la configurazione S, ma tutti e tre i siti eseguono nextxch con una replica PostgreSQL. Quando un sito si guasta restano due database e due nodi nextxch, e con synchronous_mode si mantiene persino la replica sincrona. Tre record MX con la stessa preferenza distribuiscono il carico in entrata.

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"]
Architettura setup M
Proxmox: nessun cluster tra provider diversi
Secondo Proxmox, corosync richiede latenze inferiori a 5 ms (livello LAN); oltre circa 10 ms e con più di tre nodi è improbabile che funzioni. Un singolo cluster Proxmox VE esteso su Hetzner, netcup e STRATO non è quindi consigliato. Lo schema corretto: un host o cluster Proxmox indipendente per provider, con l'alta disponibilità fornita dall'applicazione (Patroni, Garage, cluster nextxch). Un QDevice (corosync-qnetd) è pensato per cluster a due nodi in un unico sito e non è vincolato dal limite di latenza. Il Proxmox Datacenter Manager (1.0 dal 2025-12-04) gestisce più cluster insieme. [45]

Passo dopo passo

  1. Tre VM (o guest Proxmox) presso tre provider, stessa versione di Debian, PTR rispettivamente su mx1/mx2/mx3.
  2. Inventario Ansible con tre host, un unico playbook per tutto (WireGuard, etcd, Patroni, Garage, nextxch).
  3. Configurazione Patroni del setup S con un terzo host; synchronous_mode: on, con tre database facoltativamente quorum. [54]
  4. Stringa di connessione con tre host, layout Garage con tre zone, tre record MX, tre IP in SPF e nell'elenco dei nodi del failover DNS.
  5. Proxmox Backup Server facoltativo per le immagini VM: datastore locale o S3 (backend S3 supportato ufficialmente da PBS 4.2); Proxmox non documenta condivisioni di rete come una Storage Box via CIFS come tipo di 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 · Setup L: Kubernetes presso tre provider

Per molte caselle di posta e scalabilità orizzontale. Prima di tutto: k3s esplicitamente non supporta etcd integrato in un cluster distribuito su più cloud; i nodi server devono restare in un'unica sede. Quindi: un cluster Kubernetes per provider (k3s o Talos ciascuno), ognuno con un deployment nextxch dal chart Helm fornito. Il livello dati (Patroni + etcd + Garage) gira su VM dedicate tramite WireGuard come nella configurazione M ed è usato da tutti e tre i cluster. [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
Setup dell'architettura L

Perché non CloudNativePG tra cluster?

CloudNativePG può eseguire cluster replica in altri cluster Kubernetes, ma promozione e retrocessione tra cluster sono dichiarative (campo primary, promotionToken), cioè non automatiche. Per un failover automatico tra provider, Patroni con etcd su tre siti è la soluzione più diretta. [52]

Un unico cluster su tutti i provider?

Talos offre KubeSpan, una full mesh WireGuard tra i siti, e k3s un backend flannel wireguard-native. Il control plane (etcd), tuttavia, è sensibile alla latenza WAN. Più cluster piccoli sono più semplici da gestire e si guastano in modo indipendente. [51][49][50]

Passo dopo passo

  1. Costruire il livello dati come nel setup M (tre VM con Patroni, etcd, Garage).
  2. Un cluster k3s o Talos per provider; collegamento di rete al livello dati tramite WireGuard (o KubeSpan).
  3. Porte in ingresso: servizio di tipo LoadBalancer con externalTrafficPolicy: Local (come nel repository), in modo che l'IP del mittente sia preservato per SPF/DMARC; con un load balancer che supporta il protocollo PROXY usare invece Cluster__EnableProxyProtocol=true. Il Load Balancer di Hetzner Cloud supporta il protocollo PROXY e qualsiasi servizio TCP. [19]
  4. Porta 25 in uscita: egress dei pod tramite un IP fisso con PTR e voce SPF corrispondenti (egress gateway, come descritto in deploy/kubernetes/README.md).
  5. Valori Helm per provider, rollout tramite pipeline uno dopo l'altro (capitolo 07). HPA (da 2 a 10 pod al 70% di CPU) e PodDisruptionBudget sono inclusi nel repository.
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 · Deployment e aggiornamento da Git

Tutto ciò che conta è un file nel repository: file compose o valori Helm, inventario Ansible, script di failover DNS. La pipeline distribuisce sempre un solo sito alla volta e attende che /health/ready segnali di nuovo "ready" prima di proseguire. I segreti (licenza, password, token API, chiavi SSH) si trovano solo negli archivi dei segreti di GitHub/GitLab o nel file .env sul server, mai nel repository.

flowchart TB
  G["git push to main"] --> V["Validate: compose config / helm lint"]
  V --> A["Roll out site A"]
  A --> HA{"/health/ready = ready?"}
  HA -->|"yes"| B["Roll out site B"]
  HA -->|"no"| X["Abort, B stays on old version"]
  B --> HB{"/health/ready = ready?"}
  HB -->|"yes"| C["Roll out site C"]
  HB -->|"no"| X
  C --> OK["Done"]
Pipeline di deployment
GitHub Actions: siti 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 (Setup L)
# .github/workflows/deploy-k8s.yml: one Kubernetes cluster per provider, rolled one after another.
name: deploy-nextxch-k8s
on:
  push: { branches: [main], paths: ["k8s/**"] }
  workflow_dispatch:

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

jobs:
  helm:
    runs-on: ubuntu-latest
    strategy:
      max-parallel: 1
      matrix:
        cluster: [hetzner, netcup, strato]
    environment: k8s-${{ matrix.cluster }}
    steps:
      - uses: actions/checkout@v4
      - uses: azure/setup-helm@v4
      - name: Kubeconfig
        run: |
          mkdir -p ~/.kube
          echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
          chmod 600 ~/.kube/config
      - name: Helm upgrade (waits for readiness probes on /health/ready)
        run: |
          helm upgrade --install nextxch ./deploy/helm/nextxch \
            --namespace nextxch --create-namespace \
            -f k8s/values-common.yaml -f k8s/values-${{ matrix.cluster }}.yaml \
            --set image.tag=${{ vars.NEXTXCH_TAG }} \
            --wait --timeout 10m --atomic
Il job di failover non appartiene alla CI
Le esecuzioni CI pianificate non sono né precise al secondo né garantite. Per questo il failover DNS gira sui siti stessi: come agente integrato in nextxch o come servizio systemd con lo script (capitolo 04), che la pipeline si limita a distribuire.
Anche la configurazione della posta da Git
Anche domini, caselle di posta, liste, regole, SMTP e le altre impostazioni risiedono nel repository come YAML: nextxch-cli config plan commenta la pull request con il piano, config apply --prune --yes lo applica dopo il merge. Guida con file GitHub Actions e GitLab CI pronti all'uso: Configurazione come codice (GitOps).

08 · Scenari di guasto: cosa succede esattamente

Hetzner (sito A, attuale primario) va completamente offline

sequenceDiagram
  participant S as Remote mail server
  participant A as Site A (Hetzner)
  participant B as Site B (netcup)
  participant C as Witness C
  participant D as DNS
  Note over A: Provider outage
  S->>A: SMTP to mx1
  A--xS: no connection
  S->>B: SMTP to mx2 (or retry)
  B--xS: without a primary the session fails, sender retries later
  Note over B,C: etcd majority B + C, leader key of A expires (ttl 30 s)
  B->>B: Patroni promotes PostgreSQL B to primary
  B->>B: nextxch reconnects (Target Session Attributes=primary)
  S->>B: retry
  B-->>S: 250 OK (commit synchronous or asynchronous)
  C->>D: failover job removes IP A from mail.example.com
  Note over D: clients follow after the TTL expires
Sequenza quando il sito A si guasta
TempoCosa succedeCosa notano gli utenti
0 sA non è più disponibile. La posta in ingresso verso mx1 fallisce, i mittenti usano mx2 (stessa preferenza) o riprovano più tardi. [1]I client su A perdono la connessione.
fino a circa 30 sLa leader key di A in etcd scade dopo ttl = 30 s; B e C detengono la maggioranza. [53]Il nodo B restituisce 503 su /health/ready, le scritture attendono.
circa 30-60 sPatroni promuove B (loop_wait 10 s). nextxch su B trova il nuovo primario tramite la stringa di connessione multi-host (ricontrollo da 5 a 10 s). [63]B accetta di nuovo la posta.
circa 60-90 sIl failover DNS (agente: 3 round falliti × 30 s, script: 3 × 20 s) rimuove l'IP A da mail.example.com.Le nuove connessioni vanno a B non appena scade il TTL (60 s).
da 1 a 5 minSeguono i resolver con TTL minimi propri e i client con cache DNS. Garage prosegue con le zone B e C (quorum 2 su 3). [61]I client si riconnettono; le sessioni webmail restano valide (sessioni firmate e stateless).

RPO

Con synchronous_mode, PostgreSQL conferma un commit solo quando il secondo sito lo ha ricevuto; un messaggio confermato con 250 OK esiste quindi in due siti. Il prezzo: ogni commit attende almeno un round trip tra i siti. Senza synchronous_mode potrebbero mancare gli ultimi secondi prima del guasto. Con synchronous_mode_strict: false, Patroni torna alla modalità asincrona quando non rimane alcuna replica, così la posta continua a essere accettata. [56][54]

RTO

Database circa 30-60 s (valori predefiniti di Patroni), commutazione DNS circa 1 min più TTL, posta in entrata senza attesa tramite il secondo MX o il nuovo tentativo del mittente. Questi tempi sono derivati dai valori predefiniti, non misurati: li verifichi con un proprio test (arresto forzato di un sito).

netcup (sito B, replica) va offline

Il primario su A continua a funzionare. Senza un partner sincrono Patroni passa alla modalità asincrona (strict = false), il job di failover rimuove l'IP B, la posta in entrata passa tramite mx1. Garage prosegue con A e C.

È interrotto solo il collegamento tra A e B

Il lato che raggiunge ancora il witness C mantiene la maggioranza etcd e resta o diventa primario. L'altro lato perde la maggioranza, Patroni vi retrocede un eventuale primario (failsafe_mode si applica solo se può ancora raggiungere tutti i membri) e il failover DNS lì non interviene: l'agente integrato non raggiunge alcun primario e non ottiene il leader lock, lo script non ottiene il lock etcd. Non si verifica alcuno split brain. [55]

Failback

sequenceDiagram
  participant A as Site A
  participant B as Site B (primary)
  participant G as Garage
  participant D as DNS job
  A->>A: server back online, WireGuard up
  A->>B: Patroni: pg_rewind, then replica of B
  G->>A: missing objects are re-replicated
  A->>A: /health/ready = ready
  D->>D: successful checks
  D->>A: IP A back in mail.example.com
  Note over A,B: optional, planned: patronictl switchover back to A
Ritorno del sito A

Quando A torna disponibile, B resta primario. Patroni riporta A come replica con pg_rewind (use_pg_rewind: true), Garage risincronizza gli oggetti mancanti, il job di failover aggiunge di nuovo l'IP A non appena /health/ready riporta lì "ready". Riportare indietro il primario non è necessario; se lo si desidera, farlo in modo pianificato con patronictl switchover in un momento di basso carico.

09 · Backup, ripristino e GoBD

La replica protegge dai guasti, non dagli errori: una casella di posta eliminata per sbaglio scompare contemporaneamente in tutti i siti. Pertanto aggiungere backup secondo la regola 3-2-1: tre copie, due tipi di supporto, una fuori sede. [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
Flusso di backup
  • PostgreSQL: pgBackRest con archiviazione WAL continua (point-in-time recovery). Repository 1 su una Hetzner Storage Box via SFTP (pgBackRest supporta SFTP dalla 2.46; la Storage Box offre SFTP, SCP, rsync, BorgBackup, Restic, Rclone, SMB, WebDAV), repository 2 su S3 presso un altro provider. Entrambi crittografati. [58][15]
  • Blob: gli oggetti .eml sono denominati in base allo SHA-256 e non vengono mai modificati. rclone copy (non sync, quindi non elimina mai nulla) è sufficiente come copia oraria.
  • Archivio GoBD: Hetzner Object Storage supporta Object Lock in modalità GOVERNANCE e COMPLIANCE, oltre al versioning; Object Lock deve essere attivato alla creazione del bucket. Creare il bucket con una conservazione predefinita (compliance): ogni oggetto copiato viene quindi bloccato automaticamente. In alternativa nextxch può scrivere direttamente in questo bucket (backend S3 o ibrido), ma in tal caso la disponibilità dei blob dipende da Hetzner. In questo caso nextxch crea all'avvio un bucket mancante con Object Lock non appena è configurata una modalità WORM (o Storage:S3:ObjectLockEnabled); "Verifica bucket S3" nell'archivio GoBD mostra raggiungibilità, versioning, modalità Object Lock e conservazione predefinita. [18][17]
  • Ripristini di prova: almeno ogni trimestre ripristinare pgBackRest in una VM di test e aprire campioni dalla copia dei blob.
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: copia dei blob e 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 · Scalabilità: utenti, storage, pod

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"]
Percorso di scalabilità
ComponenteScalabilitàNota
Nodi / pod nextxchorizzontalmenteStateless; più nodi per sito o più pod (HPA). Nessuna sticky session necessaria.
Primario PostgreSQLverticalmenteTutte le scritture vanno a un unico primario. Più RAM e dischi più veloci (IOPS) invece di più nodi; valore iniziale di shared_buffers 25% della RAM. nextxch non usa read replica. [57]
Archivio bloborizzontalmentePiù nodi Garage per zona con capacità (garage layout assign -c), i dati vengono ribilanciati. La deduplicazione SHA-256 e la compressione Zstd riducono il fabbisogno.
SMTPtramite DNSPiù record MX con la stessa preferenza distribuiscono il carico; la coda in uscita si distribuisce su tutti i nodi tramite SKIP LOCKED.
IMAPtramite DNS / LBLe connessioni IDLE di lunga durata restano sul proprio nodo; la nuova posta dagli altri nodi arriva tramite LISTEN/NOTIFY. Dopo l'aggiunta di un nodo, il carico si distribuisce solo man mano che i client si riconnettono. [7]
HTTPS (Webmail, ActiveSync, JMAP)tramite DNS / LBSessioni HMAC stateless; qualsiasi richiesta può raggiungere qualsiasi nodo.

11 · Costi: dove risparmiare, dove no

Salva

  • Piani con vCPU condivise per nextxch e witness; core dedicati solo quando il carico CPU del database lo richiede.
  • Durata di 12 mesi presso netcup (13% sotto il prezzo mensile secondo la pagina dei prezzi). [22]
  • Failover DNS con l'agente integrato (o uno script proprio) invece di un load balancer a pagamento.
  • Compressione Zstd (Storage:Compression) e deduplicazione SHA-256 per i blob.

Dove non risparmiare

  • RAM dei nodi PostgreSQL: tutto ciò che non è in cache costa I/O su disco a ogni visualizzazione di cartella.
  • IOPS del disco per PostgreSQL (NVMe); le scritture WAL determinano la latenza di ogni recapito.
  • Il terzo sito: senza di esso non è possibile un failover automatico sicuro.
  • Backup testati su una destinazione indipendente.

Prezzi memorizzati (netti al mese, aggiornati al 2 novembre 2026). netcup e STRATO indicano prezzi lordi incl. IVA al 19%, qui divisi per 1,19. Per Hetzner si aggiungono 0,50 € per l'IPv4 primario. Alla data della verifica Hetzner mostrava tutti i piani cloud come "attualmente non disponibili"; non è stato possibile stabilire se si trattasse di disponibilità o di visualizzazione. Il prezzo dei volumi Hetzner non era leggibile dalle pagine ufficiali, quindi manca nel calcolatore. I prezzi cambiano: verificare con il provider prima dell'ordine. [12][10][22][23][30][15][16]

ProviderPianovCPURAMDisco€ netti / mese
Hetzner CloudCX2324 GB40 GB€6.49
Hetzner CloudCX3348 GB80 GB€9.49
Hetzner CloudCX43816 GB160 GB€16.99
Hetzner CloudCX531632 GB320 GB€30.49
netcup VPSVPS 500 G12.524 GB64 GB€6.94
netcup VPSVPS 1000 G12.548 GB128 GB€12.18
netcup VPSVPS 2000 G12.5816 GB256 GB€22.62
netcup VPSVPS 4000 G12.51232 GB512 GB€38.12
netcup VPSVPS 8000 G12.51664 GB1024 GB€56.39
netcup Root ServerRS 1000 G12.548 GB128 GB€18.26
netcup Root ServerRS 2000 G12.5816 GB256 GB€34.20
netcup Root ServerRS 4000 G12.51232 GB512 GB€65.40
netcup Root ServerRS 8000 G12.51664 GB1024 GB€124.72
netcup Root ServerRS 12000 G12.52096 GB1536 GB€182.26
netcup Root ServerRS 16000 G12.524128 GB2048 GB€243.24
STRATO VPSVPS S12 GB60 GB€3.36
STRATO VPSVPS M24 GB120 GB€8.40
STRATO VPSVPS L48 GB240 GB€16.81
STRATO VPSVPS XL816 GB480 GB€32.77
STRATO VPSVPS XXL1224 GB720 GB€47.06
Hetzner Storage BoxBX11--1 TB€3.20
Hetzner Storage BoxBX21--5 TB€10.90
Hetzner Storage BoxBX31--10 TB€20.80
Hetzner Storage BoxBX41--20 TB€40.60
Hetzner Object Storageprezzo base incl. 1 TB di storage + 1 TB di traffico in uscita--1 TB€6.49

12 · Calcolatore: risorse e costi

Funziona interamente nel browser, senza servizi esterni. Le ipotesi sono elencate apertamente sotto il risultato.

Ipotesi (non misurate, verificare con i propri dati)
  • Volume di posta = caselle di posta × dimensione media × (1 + crescita)^anni. Nessuna detrazione per deduplicazione o compressione (stima prudente).
  • Dimensione PostgreSQL = 3% del volume di posta (metadati, flag, indice full-text), almeno 2 GB. Il valore reale si ottiene con SELECT pg_database_size('nextxch').
  • RAM per PostgreSQL = 25% della dimensione del DB, almeno 2 GB, arrotondata a 2/4/8/16/32/64/96/128 GB. Disco per PostgreSQL = 2 × dimensione del DB (dati, WAL, margine).
  • Processo nextxch: 1 GB + 1 GB per ogni 1.000 caselle di posta iniziate (il limite Kubernetes nel repo è 1 GiB per pod). etcd + Garage + sistema operativo: 1 GB. Witness: 2 GB di RAM. Sistema operativo: 20 GB di disco.
  • Garage replication_factor 3: ogni zona (sito o witness) archivia l'intero volume di posta.
  • Setup L: almeno 2 pod per provider, 1 pod ogni 1.500 caselle di posta, worker con 8 GB; la VM dati ospita PostgreSQL, etcd e la zona Garage.
  • Fabbisogno di backup = 3 × dimensione del DB (2 backup completi + WAL) + volume della posta; obiettivo: la Hetzner Storage Box più piccola adeguata.
  • Prezzi delle licenze dall'attuale listino di questo sito (il prezzo dello scaglione si applica a tutte le caselle di posta, prezzo annuale / 12). Cloudflare è indicato in USD e non viene sommato al totale in euro.

13 · Aggiunta di un altro provider (esempio STRATO)

  1. Prima di ordinare: è possibile impostare il PTR? STRATO: reverse DNS per VPS nel login cliente alla voce domini, deve prima esistere un record A/AAAA, richiede fino a 24 h. Porta 25: per i root server STRATO documenta il traffico in uscita come bloccato, con sblocco tramite supporto; per i VPS non è documentata alcuna regola. STRATO non offre IP di failover. [31][32][30]
  2. Aggiungere il nuovo host all'inventario Ansible con il proprio IP WireGuard e la propria zona Garage, aggiungere il peer su tutti i siti, eseguire il playbook.
  3. etcd: aggiungere il membro con etcdctl member add e avviarlo con ETCD_INITIAL_CLUSTER_STATE=existing. Con quattro siti di database mantenere dispari il numero di membri etcd. [48]
  4. Patroni si avvia come replica e replica automaticamente. Garage: garage layout assign con la nuova zona, garage layout apply.
  5. DNS: aggiungere l'IP a SPF, un nuovo MX con la stessa priorità, il nodo all'elenco del failover DNS (agente o script), il record TLSA per il nuovo nome MX, il nome EHLO e il PTR del nuovo nodo, estendere il certificato con il nome. La policy MTA-STS acquisisce automaticamente il nuovo MX (elenco MX automatico); quindi pubblicare il nuovo valore TXT _mta-sts dall'advisor DNS.
  6. Aggiungere il sito alla matrice della pipeline e testare una volta un guasto del nuovo sito.

14 · Limiti e lacune (con onestà)

Individuate nel codice durante la stesura di questa guida. Quattro lacune sono ormai state colmate: MTA-STS con più nomi MX, nome EHLO per nodo, bucket Object Lock creato automaticamente e agente di failover DNS integrato. Per le restanti la guida adotta le soluzioni alternative descritte.

LacunaImpattoSoluzione alternativa
Agente di failover DNS (closed source)Integrato, da attivare: health check da ogni nodo, solo il leader del cluster modifica A/AAAA tramite Hetzner DNS o Cloudflare.Altri provider DNS (ad es. deSEC): script sotto lock etcd (capitolo 04).
MTA-STS con più MX (risolto)La policy per dominio elenca tutti i nomi MX (MX DNS, nomi di server e nodi oppure un elenco proprio con caratteri jolly); l'id della policy segue l'elenco.Dopo ogni modifica degli MX pubblicare il valore TXT _mta-sts indicato dal DNS advisor.
Nome EHLO per nodo (risolto)Smtp:HeloHostname o un'impostazione per nodo in /admin/, con controllo PTR per nodo; il nome host del server resta valido per l'intero cluster.-
Una sola destinazione S3Nessuna scrittura doppia in due archivi S3 presso provider diversi; le funzionalità Object Lock richiedono un S3 con Object Lock.Garage su tre siti più rclone copy in un bucket con Object Lock.
Bucket Object Lock (chiuso)Con una modalità WORM configurata, il server crea all'avvio un bucket mancante con Object Lock. Un bucket esistente senza Object Lock viene segnalato come avviso e alert, perché Object Lock può essere attivato solo alla creazione. [18]Verificabile in qualsiasi momento con "Verifica bucket S3" nell'archivio GoBD.
Nessun backup integratoIl server non include un servizio di backup per database e blob.pgBackRest e rclone (capitolo 09).
Nessuna read replicaTutte le query vanno al primario; le repliche di lettura sono indicate nel piano di sviluppo solo come "su richiesta".Scalare verticalmente il primario PostgreSQL.
Nessun benchmark di dimensionamento misuratoLe dimensioni di RAM e DB nel calcolatore sono ipotesi.Sovrascrivere con le proprie misurazioni.

FAQ

nextxch può funzionare in active-active su più provider?

Sì. Con Cluster:Enabled=true e PostgreSQL, un numero qualsiasi di nodi nextxch condivide lo stato tramite LISTEN/NOTIFY, tabelle UNLOGGED e advisory lock. Tutti i nodi devono raggiungere lo stesso primario PostgreSQL e lo stesso blob store S3; tra provider diversi ciò avviene tramite una rete WireGuard.

Serve un load balancer o anycast?

No. La posta in entrata richiede solo più record MX con la stessa preferenza (RFC 5321 sezione 5.1). IMAP, submission e HTTPS richiedono un nome con TTL breve i cui record A sono gestiti da un job di health check. Anycast/BGP è possibile, ma complesso.

Quanti siti servono come minimo?

Tre voti: due siti completi più un piccolo witness (etcd, zona Garage, failover checker), oppure tre siti completi. Con solo due siti nessuna delle due parti può stabilire con certezza se l'altra è guasta o se si è interrotto solo il collegamento (split brain).

La posta va persa quando un provider va offline?

I mittenti continuano a riprovare per almeno 4-5 giorni (RFC 5321 sezione 4.5.4.1). Con Patroni synchronous_mode e Garage replication_factor 3, un messaggio confermato con 250 OK è già archiviato in un secondo sito.

15 · Fonti

Tutti i fatti esterni sono stati verificati il 2 novembre 2026 sulle seguenti fonti ufficiali. I punti che non è stato possibile verificare sono indicati come tali nel testo.

  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 di lookup DNS, 5.6 ip4/ip6: https://www.rfc-editor.org/rfc/rfc7208.html
  3. RFC 6376 (DKIM): 3.1 selettori: https://www.rfc-editor.org/rfc/rfc6376.html
  4. RFC 8461 (MTA-STS): 3.2, 4.1 pattern mx: https://www.rfc-editor.org/rfc/rfc8461.html
  5. RFC 7672 (DANE per SMTP): 3.1 TLSA 3 1 1: https://www.rfc-editor.org/rfc/rfc7672.html
  6. RFC 8314 (TLS implicito 465/993/995): https://www.rfc-editor.org/rfc/rfc8314.html
  7. RFC 2177 (IMAP IDLE, da rinnovare ogni 29 minuti): https://www.rfc-editor.org/rfc/rfc2177.html
  8. Google: linee guida per i mittenti di e-mail (DNS diretto e inverso): https://support.google.com/a/answer/81126
  9. Hetzner Docs: Floating IP: https://docs.hetzner.com/cloud/floating-ips/overview
  10. Hetzner Docs: prezzi IPv4: https://docs.hetzner.com/general/others/ipv4-pricing
  11. Hetzner Docs: IP di failover Robot: https://docs.hetzner.com/robot/dedicated-server/ip/failover
  12. Hetzner Cloud: piani cost-optimized: https://www.hetzner.com/cloud/cost-optimized/
  13. Hetzner Docs: FAQ sui cloud server (porte 25/465): https://docs.hetzner.com/cloud/servers/faq/
  14. Hetzner Docs: record PTR: https://docs.hetzner.com/networking/dns/record-types/ptr-record/
  15. Hetzner Storage Box (prezzi, protocolli): https://www.hetzner.com/storage/storage-box/
  16. Hetzner Object Storage (prezzi): https://www.hetzner.com/storage/object-storage/
  17. Hetzner Docs: panoramica Object Storage: https://docs.hetzner.com/storage/object-storage/overview
  18. Hetzner Docs: Object Lock e conservazione: 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 sulle reti: https://docs.hetzner.com/cloud/networks/faq
  21. Hetzner Docs: DNS (Console e API): https://docs.hetzner.com/networking/dns/overview/
  22. netcup: prezzi VPS G12.5: https://www.netcup.com/de/server/vps
  23. netcup: prezzi Root Server G12.5: https://www.netcup.com/de/server/root-server
  24. netcup Helpcenter: indirizzi IP e failover: https://www.netcup.com/en/helpcenter/documentation/server/ip
  25. netcup: estensioni server (prezzi degli IP di failover): https://www.netcup.com/de/server/kvm-server-erweiterungen
  26. netcup Helpcenter: firewall (policy "netcup Mail block"): https://www.netcup.com/en/helpcenter/documentation/server/firewall
  27. netcup Helpcenter: rete / rDNS: https://www.netcup.com/en/helpcenter/documentation/server/network-server
  28. netcup: Storage Space (NFS): https://www.netcup.com/en/server/storage-space
  29. netcup Helpcenter: DNS API: https://www.netcup.com/en/helpcenter/documentation/domain/our-api
  30. STRATO: prezzi VPS Linux: https://www.strato.de/server/linux-vserver/
  31. STRATO FAQ: reverse DNS per VPS: https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
  32. STRATO FAQ: firewall di rete del root server (porta 25): https://www.strato.de/faq/server/wie-stelle-ich-die-netzwerk-firewall-bei-meinem-windows-root-server-ein/
  33. Cloudflare: prezzo del componente aggiuntivo Load Balancing: https://www.cloudflare.com/plans/network-cdn/
  34. Cloudflare Docs: limitazioni del Load Balancing (intervallo di health check): https://developers.cloudflare.com/load-balancing/reference/limitations/
  35. Cloudflare Docs: record DNS del Load Balancing (MX/SRV solo Enterprise): https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
  36. Cloudflare Docs: modalità proxy (solo DNS): https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
  37. Cloudflare Docs: protocolli Spectrum per piano: https://developers.cloudflare.com/spectrum/protocols-per-plan/
  38. Cloudflare Docs: DNS TTL: https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/
  39. API Cloudflare: modificare un record DNS: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
  40. deSEC: domini e TTL minimo: https://desec.readthedocs.io/en/latest/dns/domains.html
  41. Akamai/Linode: IP failover (BGP, stesso data center): https://techdocs.akamai.com/cloud-computing/docs/configure-failover-on-a-compute-instance
  42. Akamai: prezzi NodeBalancer: https://www.akamai.com/cloud/pricing/nodebalancers
  43. Vultr: requisiti BGP: https://docs.vultr.com/configuring-bgp-on-vultr
  44. RIPE NCC: richiesta di un numero AS: https://www.ripe.net/manage-ips-and-asns/as-numbers/request-an-as-number/
  45. Proxmox VE: Cluster Manager (latenza corosync, QDevice): https://pve.proxmox.com/wiki/Cluster_Manager
  46. Proxmox Backup Server: datastore (incl. S3): https://pbs.proxmox.com/docs/storage.html
  47. WireGuard: https://www.wireguard.com/
  48. k3s: HA con etcd integrato: https://docs.k3s.io/datastore/ha-embedded
  49. k3s: cluster ibrido distribuito o multicloud: https://docs.k3s.io/networking/distributed-multicloud
  50. etcd: tuning (heartbeat, election timeout): https://etcd.io/docs/v3.5/tuning/
  51. Talos Linux: KubeSpan: https://docs.siderolabs.com/talos/v1.11/networking/kubespan
  52. CloudNativePG: replica cluster: https://cloudnative-pg.io/docs/current/replica_cluster
  53. Patroni: configurazione dinamica (ttl, loop_wait, retry_timeout): https://patroni.readthedocs.io/en/latest/dynamic_configuration.html
  54. Patroni: modalità di replica (synchronous_mode, strict): https://patroni.readthedocs.io/en/latest/replication_modes.html
  55. Patroni: modalità failsafe DCS: https://patroni.readthedocs.io/en/latest/dcs_failsafe_mode.html
  56. PostgreSQL 18: replica sincrona: 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 (repository S3 e 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: pagina del progetto (multi-datacenter, 200 ms / 50 Mbps): https://garagehq.deuxfleurs.fr/
  61. Garage: configurazione (replication_factor, quorum): https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
  62. Garage: compatibilità S3 (senza Object Lock, senza versioning): https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/
  63. Npgsql: failover e bilanciamento del carico (multi-host): https://www.npgsql.org/doc/failover-and-load-balancing.html
  64. Mermaid 12.2.0 (MIT), incluso in questo sito: https://www.npmjs.com/package/mermaid