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à | Come | Sorgente nel codice |
|---|---|---|
| Backplane del cluster senza Redis | PostgreSQL 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 heartbeat | src/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs |
| Attività del leader | pg_try_advisory_lock: esattamente un nodo rinnova i certificati ACME, invia l'heartbeat della licenza, sincronizza LDAP, invia i promemoria del calendario | AcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs |
| Coda SMTP tra i nodi | FOR UPDATE SKIP LOCKED: ogni nodo prende in carico elementi diversi della coda, nessun doppio recapito | src/Nextxch.Storage/Postgres/PostgresMailStore.cs |
| Database con supporto cluster | PostgreSQL (predefinito per i cluster) e SQL Server (sp_getapplock); SQLite solo a nodo singolo | src/Nextxch.Storage/StorageServiceCollectionExtensions.cs |
| Archivio blob | FileSystem, S3 (client SigV4 proprio con Content-MD5) o ibrido (cache NVMe locale + S3). Modalità Object Lock Normal, Governance, Compliance e legal hold | src/Nextxch.Storage/Blobs/S3BlobStore.cs, HybridBlobStore.cs |
| Certificati & DKIM | Certificati 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 client | Protocollo PROXY v1/v2 per SMTP, IMAP, ManageSieve (Cluster:EnableProxyProtocol) e X-Forwarded-For per HTTP da TrustedProxyCidrs | src/Nextxch.Core/Abstractions/IClusterCoordinator.cs |
| Artefatti di deployment | docker-compose.yml, manifest Kubernetes (3 repliche, HPA da 2 a 10, PDB, probe), chart Helm, deb/rpm, Windows MSI | deploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/ |
| Licenza | Si contano le caselle di posta attive, non i nodi o i pod; lo stato della licenza è condiviso tramite cluster_kv | src/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).
/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]
; Zone example.com (TTL in seconds). IPs are documentation ranges: replace them.
; Site A = Hetzner 192.0.2.10, site B = netcup 198.51.100.20, site C (medium setup) = 203.0.113.30
; Inbound SMTP: equal preference, senders MUST randomize (RFC 5321 section 5.1).
example.com. 3600 IN MX 10 mx1.example.com.
example.com. 3600 IN MX 10 mx2.example.com.
; Static per-site names: never touched by the failover job (stable FCrDNS for PTR).
mx1.example.com. 3600 IN A 192.0.2.10
mx2.example.com. 3600 IN A 198.51.100.20
; Client name (IMAP, submission, HTTPS, webmail, ActiveSync, autodiscover):
; short TTL, kept up to date by the health-check job. Start with all healthy nodes.
mail.example.com. 60 IN A 192.0.2.10
mail.example.com. 60 IN A 198.51.100.20
; SPF: every outbound IP of every site. ip4/ip6 do not count against the 10-lookup limit (RFC 7208 4.6.4).
example.com. 3600 IN TXT "v=spf1 ip4:192.0.2.10 ip4:198.51.100.20 ip4:203.0.113.30 -all"
; DKIM: nextxch keeps the keys in the cluster database, so one record set serves all nodes.
; Publish the selectors shown under /admin/ (Domains > DKIM).
; MTA-STS: the policy must list every MX host (see the note on gaps).
_mta-sts.example.com. 3600 IN TXT "v=STSv1; id=20261102"
; DANE (optional, needs a DNSSEC-signed zone): one TLSA record per MX host.
; nextxch reuses the leaf key on renewal, so "3 1 1" stays stable.
_25._tcp.mx1.example.com. 3600 IN TLSA 3 1 1 <sha256-of-spki>
_25._tcp.mx2.example.com. 3600 IN TLSA 3 1 1 <sha256-of-spki>
; PTR (set in the provider console, not in this zone):
; 192.0.2.10 -> mx1.example.com (Hetzner Console / Robot)
; 198.51.100.20 -> mx2.example.com (netcup SCP)
Smtp:HeloHostname 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]# appsettings.Production.json per node (or admin: Hostname, IPv6 & Ports > EHLO name per node)
# Site A (Hetzner):
{ "Smtp": { "ServerHostName": "mail.example.com", "HeloHostname": "mx1.example.com" } }
# Site B (netcup):
{ "Smtp": { "ServerHostName": "mail.example.com", "HeloHostname": "mx2.example.com" } }
# PTR 192.0.2.10 -> mx1.example.com, PTR 198.51.100.20 -> mx2.example.com
# Served by nextxch itself at https://mta-sts.example.com/.well-known/mta-sts.txt
# (admin: Domains > domain > Transport > MTA-STS). Empty MX list = automatic:
# the domain's MX records from DNS + server host name + EHLO names of all nodes.
# Wildcards like *.example.com are allowed. Every change changes the policy id;
# the DNS advisor shows the matching _mta-sts TXT record.
version: STSv1
mode: enforce
mx: mx1.example.com
mx: mx2.example.com
max_age: 604800
03 · 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.
| Opzione | Ambito | Costi | Valutazione |
|---|---|---|---|
| Più MX con la stessa priorità | su più provider | 0 € | Obbligatorio per la posta in entrata, standardizzato [1] |
| Job di health check proprio + API DNS (Cloudflare, Hetzner, deSEC, netcup) | su più provider | 0 € | 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ù provider | da 5 USD/mese | Health 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 Spectrum | su più provider | Enterprise | Porte 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 rete | IPv4 3,00 €, IPv6 1,00 €/mese | Solo all'interno di Hetzner [9] |
| IP di failover Hetzner (Robot, dedicato) | Data center Hetzner | 4,20 €/mese + 4,90 € di attivazione | Commutazione tramite Robot API in 90-110 s, solo all'interno di Hetzner [11] |
| IP di failover netcup | stessa sede netcup | 5,00 €/mese IVA inclusa | Reinstradamento nel SCP in qualsiasi momento e gratuito, solo all'interno di netcup [24][25] |
| Condivisione IP Akamai/Linode | un data center, due Linode | da 5 USD (Nanode) | Basato su BGP (lelastic), solo nello stesso data center; NodeBalancer Basic 10 USD, TCP/HTTP [41][42] |
| Anycast / BGP proprio | globale | alto | ASN 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] |
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
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]
Passo dopo passo
- 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]
- Impostare il PTR: IP A su mx1.example.com (Hetzner Console), IP B su mx2.example.com (netcup SCP). [14][27]
- Installazione di base tramite cloud-init (Debian 13): utente di deploy, Docker, WireGuard, nessun accesso con password.
- Mesh WireGuard 10.88.0.0/24 tra A, B, C; aprire UDP 51820 solo per gli IP dei peer. [47]
- etcd su tutti e tre (election timeout almeno 10 × RTT), poi Patroni + PostgreSQL 18 su A e B. [50][53]
- Garage su tutti e tre, applicare un layout con tre zone, creare bucket e chiave. [61]
- 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.
- 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.
- Backup come nel capitolo 09 e testare un ripristino. Poi simulare un guasto: arrestare bruscamente il sito A e seguire il capitolo 08.
#cloud-config
# Hetzner Cloud / netcup: paste as user data when creating the VM.
users:
- name: deploy
groups: [sudo, docker]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ssh-ed25519 AAAA... ci-deploy
package_update: true
packages: [wireguard, curl, jq, docker.io, docker-compose-v2, unattended-upgrades]
runcmd:
- [ systemctl, enable, --now, docker ]
- [ sed, -i, "s/^#\\?PasswordAuthentication.*/PasswordAuthentication no/", /etc/ssh/sshd_config ]
- [ systemctl, restart, ssh ]
# /etc/wireguard/wg0.conf on site A (Hetzner). Same pattern on B and C.
# Full mesh: every site has one [Peer] block per other site.
[Interface]
Address = 10.88.0.1/24
ListenPort = 51820
PrivateKey = <private-key-of-site-A> # wg genkey, never commit it
[Peer] # site B (netcup)
PublicKey = <public-key-of-site-B>
Endpoint = 198.51.100.20:51820
AllowedIPs = 10.88.0.2/32
PersistentKeepalive = 25
[Peer] # site C (witness or third site)
PublicKey = <public-key-of-site-C>
Endpoint = 203.0.113.30:51820
AllowedIPs = 10.88.0.3/32
PersistentKeepalive = 25
# systemctl enable --now wg-quick@wg0
# Firewall: UDP 51820 open only for the two peer IPs.
# /etc/default/etcd on site A (Debian package etcd-server). Sites B and C analogue.
# Three members = quorum of two: any single site may fail.
ETCD_NAME="site-a"
ETCD_DATA_DIR="/var/lib/etcd/default"
ETCD_LISTEN_PEER_URLS="http://10.88.0.1:2380"
ETCD_LISTEN_CLIENT_URLS="http://10.88.0.1:2379,http://127.0.0.1:2379"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://10.88.0.1:2380"
ETCD_ADVERTISE_CLIENT_URLS="http://10.88.0.1:2379"
ETCD_INITIAL_CLUSTER="site-a=http://10.88.0.1:2380,site-b=http://10.88.0.2:2380,site-c=http://10.88.0.3:2380"
ETCD_INITIAL_CLUSTER_STATE="new"
ETCD_INITIAL_CLUSTER_TOKEN="nextxch-ha"
# WAN tuning (etcd tuning guide: election timeout at least 10x the round trip).
# Example for up to ~25 ms RTT between providers:
ETCD_HEARTBEAT_INTERVAL="250"
ETCD_ELECTION_TIMEOUT="2500"
# Traffic stays inside WireGuard. For production add TLS (ETCD_CERT_FILE etc.).
# /etc/patroni/config.yml on site A (Debian/PGDG package "patroni").
# Site B: name/addresses analogue. The witness C runs etcd only, no PostgreSQL.
scope: nextxch
name: pg-site-a
restapi:
listen: 10.88.0.1:8008
connect_address: 10.88.0.1:8008
etcd3:
hosts: 10.88.0.1:2379,10.88.0.2:2379,10.88.0.3:2379
bootstrap:
dcs:
ttl: 30 # defaults; rule: loop_wait + 2 * retry_timeout <= ttl
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
synchronous_mode: on # a 250 OK to a sender is only given after the commit reached a 2nd site
synchronous_mode_strict: false # if the replica site is gone, keep accepting mail (falls back to async)
failsafe_mode: true
postgresql:
use_pg_rewind: true
parameters:
wal_level: replica
hot_standby: "on"
max_wal_senders: 10
max_replication_slots: 10
wal_log_hints: "on"
archive_mode: "on"
archive_command: "pgbackrest --stanza=nextxch archive-push %p"
initdb: [ {encoding: UTF8}, data-checksums ]
postgresql:
listen: 10.88.0.1:5432
connect_address: 10.88.0.1:5432
data_dir: /var/lib/postgresql/18/main
bin_dir: /usr/lib/postgresql/18/bin
authentication:
replication: { username: replicator, password: "${PATRONI_REPLICATION_PASSWORD}" }
superuser: { username: postgres, password: "${PATRONI_SUPERUSER_PASSWORD}" }
pg_hba:
- host replication replicator 10.88.0.0/24 scram-sha-256
- host nextxch nextxch 10.88.0.0/24 scram-sha-256
# /etc/garage.toml, identical on all three sites except rpc_public_addr.
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 3 # one copy per zone (site); writes need 2 of 3
consistency_mode = "consistent"
rpc_bind_addr = "10.88.0.1:3901"
rpc_public_addr = "10.88.0.1:3901"
rpc_secret = "<openssl rand -hex 32, same on all nodes>"
[s3_api]
s3_region = "garage"
api_bind_addr = "127.0.0.1:3900" # each nextxch node talks to its local Garage
# After the first start (node ids from `garage node id`):
# garage layout assign <id-a> -z hetzner -c 500G
# garage layout assign <id-b> -z netcup -c 500G
# garage layout assign <id-c> -z witness -c 500G
# garage layout show && garage layout apply --version 1
# garage bucket create nextxch
# garage key create nextxch-app
# garage bucket allow --read --write --owner nextxch --key nextxch-app
# docker-compose.yml on site A and site B (nextxch only; PostgreSQL/Patroni, etcd,
# Garage and WireGuard run as system services on the same VM).
# Secrets come from .env (never committed): NEXTXCH_LICENSE, PG_PASSWORD, S3_KEY, S3_SECRET
name: nextxch
services:
nextxch:
image: nextxch/server:${NEXTXCH_TAG}
restart: unless-stopped
network_mode: host # real client IPs for SPF/DMARC, direct WireGuard reach
environment:
ASPNETCORE_URLS: "http://*:80;https://*:443"
License__Token: "${NEXTXCH_LICENSE}"
Cluster__Enabled: "true"
Cluster__NodeId: "${SITE_NAME}" # hetzner-a / netcup-b
Storage__Provider: "Postgres"
# Npgsql multi-host: always connects to the current Patroni primary
Storage__PostgresConnectionString: "Host=10.88.0.1,10.88.0.2;Port=5432;Database=nextxch;Username=nextxch;Password=${PG_PASSWORD};Target Session Attributes=primary;Host Recheck Seconds=5"
Storage__BlobBackend: "S3"
Storage__Compression: "Zstd"
Storage__S3__EndpointUrl: "http://127.0.0.1:3900"
Storage__S3__Region: "garage"
Storage__S3__BucketName: "nextxch"
Storage__S3__ForcePathStyle: "true"
Storage__S3__AccessKey: "${S3_KEY}"
Storage__S3__SecretKey: "${S3_SECRET}"
Smtp__ServerHostName: "mail.example.com"
Imap__ServerHostName: "mail.example.com"
ManageSieve__ServerHostName: "mail.example.com"
Acme__Enabled: "true"
Acme__ContactEmail: "postmaster@example.com"
volumes:
- nextxch-data:/app/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1/health/ready"]
interval: 15s
timeout: 5s
retries: 3
volumes:
nextxch-data:
La 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.
# Admin: Domains > DNS failover (cluster). Stored cluster-wide; the token is encrypted
# with the server keyring and never shown again. Equivalent settings:
Provider: Hetzner DNS (Cloud API, api.hetzner.cloud) | Cloudflare DNS
Zone: example.com
Names: mail.example.com # A/AAAA follow the healthy nodes
TTL: 60 # resolvers keep old answers this long
Interval: 30 s
Failures (N): 3 # 3 x 30 s before a node leaves DNS
Successes (M): 5 # 5 x 30 s before it returns
Checks: SMTP 25 (220 banner), HTTPS /health
Nodes: fsn1, 192.0.2.10, 2001:db8::10
nbg1, 198.51.100.20
# Alert: rule "DNS failover: node taken out" (Alerts) fires when a node is out of DNS.
Target Session Attributes=primary 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]
#!/usr/bin/env bash
# /usr/local/bin/nextxch-dns-failover.sh
# ALTERNATIVE to the built-in agent (admin: Domains > DNS failover), e.g. for DNS
# providers without a built-in connector (deSEC) or when etcd decides anyway.
# Keeps the A records of mail.example.com equal to the set of healthy nodes.
# Runs on every site under an etcd lock, so only one copy acts, and only on the
# side that still has etcd quorum (a cut-off minority site can never change DNS).
# ExecStart=etcdctl --endpoints=http://127.0.0.1:2379 lock nextxch-dns /usr/local/bin/nextxch-dns-failover.sh
# Needs: curl, jq. Secrets via systemd EnvironmentFile (CF_API_TOKEN, CF_ZONE_ID).
set -euo pipefail
NAME="mail.example.com"
TTL=60
NODES=("192.0.2.10" "198.51.100.20") # public IPs of the nextxch nodes
FAILS_BEFORE_REMOVE=3 # 3 x 20 s = 1 minute
STATE=/run/nextxch-failover; mkdir -p "$STATE"
API="https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records"
AUTH=(-H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json")
healthy() { # HTTPS readiness through the public IP, with SNI for the real name
curl -fsS --max-time 5 --resolve "${NAME}:443:$1" "https://${NAME}/health/ready" \
| jq -e '.status == "ready"' >/dev/null
}
while true; do
want=()
for ip in "${NODES[@]}"; do
f="$STATE/$ip"; n=$(cat "$f" 2>/dev/null || echo 0)
if healthy "$ip"; then echo 0 > "$f"; want+=("$ip")
else n=$((n+1)); echo "$n" > "$f"; [ "$n" -lt "$FAILS_BEFORE_REMOVE" ] && want+=("$ip"); fi
done
# Never publish an empty set: if everything looks down, the checker is probably the problem.
[ "${#want[@]}" -eq 0 ] && { sleep 20; continue; }
have=$(curl -fsS "${AUTH[@]}" "${API}?type=A&name=${NAME}" | jq -r '.result[] | "\(.id) \(.content)"')
for ip in "${want[@]}"; do # add missing healthy IPs
grep -q " ${ip}$" <<<"$have" || curl -fsS "${AUTH[@]}" -X POST "$API" \
--data "{\"type\":\"A\",\"name\":\"${NAME}\",\"content\":\"${ip}\",\"ttl\":${TTL},\"proxied\":false}" >/dev/null
done
while read -r id ip; do # remove IPs that are no longer healthy
[ -z "${id:-}" ] && continue
printf '%s\n' "${want[@]}" | grep -qx "$ip" || curl -fsS "${AUTH[@]}" -X DELETE "${API}/${id}" >/dev/null
done <<<"$have"
sleep 20
done
# /etc/systemd/system/nextxch-dns-failover.service (install on every site)
[Unit]
Description=nextxch DNS failover (runs only while holding the etcd lock)
After=network-online.target etcd.service wg-quick@wg0.service
Wants=network-online.target
[Service]
EnvironmentFile=/etc/nextxch/dns-failover.env
ExecStart=/usr/bin/etcdctl --endpoints=http://127.0.0.1:2379 lock nextxch-dns /usr/local/bin/nextxch-dns-failover.sh
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
05 · Setup M: 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"]
Passo dopo passo
- Tre VM (o guest Proxmox) presso tre provider, stessa versione di Debian, PTR rispettivamente su mx1/mx2/mx3.
- Inventario Ansible con tre host, un unico playbook per tutto (WireGuard, etcd, Patroni, Garage, nextxch).
- Configurazione Patroni del setup S con un terzo host; synchronous_mode: on, con tre database facoltativamente quorum. [54]
- 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.
- 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]
# inventory/ha.yml: three sites, one VM (or Proxmox guest) per site
all:
vars:
ansible_user: deploy
wg_network: 10.88.0.0/24
pg_major: 18
children:
nextxch_sites:
hosts:
hetzner-a: { ansible_host: 192.0.2.10, wg_ip: 10.88.0.1, garage_zone: hetzner }
netcup-b: { ansible_host: 198.51.100.20, wg_ip: 10.88.0.2, garage_zone: netcup }
third-c: { ansible_host: 203.0.113.30, wg_ip: 10.88.0.3, garage_zone: third }
# site.yml: idempotent base for every site (same playbook for Hetzner, netcup, Strato ...)
- hosts: nextxch_sites
become: true
tasks:
- name: Base packages (Debian 13)
ansible.builtin.apt:
name: [wireguard, etcd-server, etcd-client, patroni, pgbackrest, docker.io, docker-compose-v2, jq, curl]
update_cache: true
# PostgreSQL 18 itself comes from the PGDG apt repository (apt.postgresql.org),
# Garage as the static binary or the dxflrs/garage image.
- name: WireGuard config
ansible.builtin.template: { src: wg0.conf.j2, dest: /etc/wireguard/wg0.conf, mode: "0600" }
notify: restart wg
- name: etcd, Patroni and Garage config
ansible.builtin.template: { src: "{{ item.src }}", dest: "{{ item.dest }}", mode: "0640" }
loop:
- { src: etcd.env.j2, dest: /etc/default/etcd }
- { src: patroni.yml.j2, dest: /etc/patroni/config.yml }
- { src: garage.toml.j2, dest: /etc/garage.toml }
- name: nextxch compose file
ansible.builtin.template: { src: docker-compose.yml.j2, dest: /opt/nextxch/docker-compose.yml }
- name: Start nextxch
ansible.builtin.command: docker compose up -d
args: { chdir: /opt/nextxch }
changed_when: false
handlers:
- name: restart wg
ansible.builtin.service: { name: wg-quick@wg0, state: restarted }
06 · Setup L: Kubernetes 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
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
- Costruire il livello dati come nel setup M (tre VM con Patroni, etcd, Garage).
- Un cluster k3s o Talos per provider; collegamento di rete al livello dati tramite WireGuard (o KubeSpan).
- 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 inveceCluster__EnableProxyProtocol=true. Il Load Balancer di Hetzner Cloud supporta il protocollo PROXY e qualsiasi servizio TCP. [19] - 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).
- 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.
# 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"]
# .github/workflows/deploy.yml: rolling update of the compose sites, one site at a time.
name: deploy-nextxch
on:
push:
branches: [main]
paths: ["sites/**", "compose/**"]
workflow_dispatch:
concurrency: { group: deploy-nextxch, cancel-in-progress: false }
jobs:
deploy:
runs-on: ubuntu-latest
strategy:
max-parallel: 1 # never update two sites at once
matrix:
site: [hetzner-a, netcup-b]
environment: ${{ matrix.site }}
steps:
- uses: actions/checkout@v4
- name: Write SSH key
run: |
install -m 700 -d ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
echo "${{ vars.HOST_KEY }}" >> ~/.ssh/known_hosts
- name: Upload compose file and roll the container
env:
HOST: ${{ vars.SSH_HOST }}
run: |
scp compose/docker-compose.yml "deploy@${HOST}:/opt/nextxch/docker-compose.yml"
scp "sites/${{ matrix.site }}.env.tag" "deploy@${HOST}:/opt/nextxch/tag.env"
ssh "deploy@${HOST}" 'cd /opt/nextxch && docker compose --env-file .env --env-file tag.env pull && docker compose --env-file .env --env-file tag.env up -d'
- name: Wait until the node is ready again
env:
HOST: ${{ vars.SSH_HOST }}
run: |
for i in $(seq 1 30); do
ssh "deploy@${HOST}" 'curl -fsS http://127.0.0.1/health/ready' && exit 0
sleep 10
done
echo "node did not become ready" >&2; exit 1
# .gitlab-ci.yml: the same rolling update with GitLab CI
stages: [validate, deploy-a, deploy-b]
variables:
NEXTXCH_TAG: "latest" # pin a released version in production
validate:
stage: validate
image: docker:27
script:
- docker compose -f compose/docker-compose.yml config -q
.deploy: &deploy
image: alpine:3.22
before_script:
- apk add --no-cache openssh-client curl
- install -m 700 -d ~/.ssh && echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
- echo "$HOST_KEY" >> ~/.ssh/known_hosts
script:
- scp compose/docker-compose.yml "deploy@$SSH_HOST:/opt/nextxch/"
- ssh "deploy@$SSH_HOST" "cd /opt/nextxch && NEXTXCH_TAG=$NEXTXCH_TAG docker compose pull && NEXTXCH_TAG=$NEXTXCH_TAG docker compose up -d"
- for i in $(seq 1 30); do ssh "deploy@$SSH_HOST" 'curl -fsS http://127.0.0.1/health/ready' && exit 0; sleep 10; done; exit 1
resource_group: production # serializes deployments
rules: [ { if: '$CI_COMMIT_BRANCH == "main"' } ]
deploy-hetzner-a:
<<: *deploy
stage: deploy-a
environment: { name: hetzner-a }
deploy-netcup-b:
<<: *deploy
stage: deploy-b
environment: { name: netcup-b }
# .github/workflows/deploy-k8s.yml: one Kubernetes cluster per provider, rolled one after another.
name: deploy-nextxch-k8s
on:
push: { branches: [main], paths: ["k8s/**"] }
workflow_dispatch:
concurrency: { group: deploy-k8s, cancel-in-progress: false }
jobs:
helm:
runs-on: ubuntu-latest
strategy:
max-parallel: 1
matrix:
cluster: [hetzner, netcup, strato]
environment: k8s-${{ matrix.cluster }}
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
- name: Kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Helm upgrade (waits for readiness probes on /health/ready)
run: |
helm upgrade --install nextxch ./deploy/helm/nextxch \
--namespace nextxch --create-namespace \
-f k8s/values-common.yaml -f k8s/values-${{ matrix.cluster }}.yaml \
--set image.tag=${{ vars.NEXTXCH_TAG }} \
--wait --timeout 10m --atomic
nextxch-cli config plan 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
| Tempo | Cosa succede | Cosa notano gli utenti |
|---|---|---|
| 0 s | A 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 s | La 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 s | Patroni 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 s | Il 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 min | Seguono 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
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
- 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.
# /etc/pgbackrest/pgbackrest.conf on every PostgreSQL site.
# Repo 1: Hetzner Storage Box over SFTP (pgBackRest 2.46+), repo 2: S3 at another provider.
[global]
repo1-type=sftp
repo1-path=/pgbackrest
repo1-sftp-host=uXXXXXX.your-storagebox.de
repo1-sftp-host-port=23
repo1-sftp-host-user=uXXXXXX
repo1-sftp-private-key-file=/var/lib/postgresql/.ssh/id_ed25519
repo1-sftp-host-key-hash-type=sha256
repo1-retention-full=2
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<long random secret, stored outside the repo>
repo2-type=s3
repo2-path=/pgbackrest
repo2-s3-endpoint=<s3 endpoint of the second provider>
repo2-s3-bucket=nextxch-pg-backup
repo2-s3-region=<region>
repo2-s3-uri-style=path
repo2-retention-full=4
repo2-cipher-type=aes-256-cbc
repo2-cipher-pass=<second secret>
archive-async=y
process-max=2
start-fast=y
[nextxch]
pg1-path=/var/lib/postgresql/18/main
pg1-port=5432
# Weekly full, daily differential (cron on every node; pgBackRest runs only where it can,
# use --repo and the "backup-standby" option to take backups from a replica).
# 0 2 * * 0 pgbackrest --stanza=nextxch --type=full backup
# 0 2 * * 1-6 pgbackrest --stanza=nextxch --type=diff backup
# Blob copy (all .eml objects) from Garage to an independent target, every hour.
# ~/.config/rclone/rclone.conf (keys are placeholders)
[garage]
type = s3
provider = Other
endpoint = http://127.0.0.1:3900
region = garage
access_key_id = <garage key>
secret_access_key = <garage secret>
[archive]
type = s3
provider = Other
endpoint = <object storage endpoint>
access_key_id = <key>
secret_access_key = <secret>
# Content-addressed (SHA-256 names, never modified): "copy" is enough and never deletes.
# rclone copy garage:nextxch archive:nextxch-archive --immutable --transfers 8 --checksum
# For a GoBD copy create the target bucket WITH Object Lock and a default retention
# (COMPLIANCE, e.g. 10 years) so every copied object is locked automatically.
10 · 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"]
| Componente | Scalabilità | Nota |
|---|---|---|
| Nodi / pod nextxch | orizzontalmente | Stateless; più nodi per sito o più pod (HPA). Nessuna sticky session necessaria. |
| Primario PostgreSQL | verticalmente | Tutte 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 blob | orizzontalmente | Più 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. |
| SMTP | tramite DNS | Più record MX con la stessa preferenza distribuiscono il carico; la coda in uscita si distribuisce su tutti i nodi tramite SKIP LOCKED. |
| IMAP | tramite DNS / LB | Le 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 / LB | Sessioni 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]
| Provider | Piano | vCPU | RAM | Disco | € netti / mese |
|---|---|---|---|---|---|
| Hetzner Cloud | CX23 | 2 | 4 GB | 40 GB | €6.49 |
| Hetzner Cloud | CX33 | 4 | 8 GB | 80 GB | €9.49 |
| Hetzner Cloud | CX43 | 8 | 16 GB | 160 GB | €16.99 |
| Hetzner Cloud | CX53 | 16 | 32 GB | 320 GB | €30.49 |
| netcup VPS | VPS 500 G12.5 | 2 | 4 GB | 64 GB | €6.94 |
| netcup VPS | VPS 1000 G12.5 | 4 | 8 GB | 128 GB | €12.18 |
| netcup VPS | VPS 2000 G12.5 | 8 | 16 GB | 256 GB | €22.62 |
| netcup VPS | VPS 4000 G12.5 | 12 | 32 GB | 512 GB | €38.12 |
| netcup VPS | VPS 8000 G12.5 | 16 | 64 GB | 1024 GB | €56.39 |
| netcup Root Server | RS 1000 G12.5 | 4 | 8 GB | 128 GB | €18.26 |
| netcup Root Server | RS 2000 G12.5 | 8 | 16 GB | 256 GB | €34.20 |
| netcup Root Server | RS 4000 G12.5 | 12 | 32 GB | 512 GB | €65.40 |
| netcup Root Server | RS 8000 G12.5 | 16 | 64 GB | 1024 GB | €124.72 |
| netcup Root Server | RS 12000 G12.5 | 20 | 96 GB | 1536 GB | €182.26 |
| netcup Root Server | RS 16000 G12.5 | 24 | 128 GB | 2048 GB | €243.24 |
| STRATO VPS | VPS S | 1 | 2 GB | 60 GB | €3.36 |
| STRATO VPS | VPS M | 2 | 4 GB | 120 GB | €8.40 |
| STRATO VPS | VPS L | 4 | 8 GB | 240 GB | €16.81 |
| STRATO VPS | VPS XL | 8 | 16 GB | 480 GB | €32.77 |
| STRATO VPS | VPS XXL | 12 | 24 GB | 720 GB | €47.06 |
| Hetzner Storage Box | BX11 | - | - | 1 TB | €3.20 |
| Hetzner Storage Box | BX21 | - | - | 5 TB | €10.90 |
| Hetzner Storage Box | BX31 | - | - | 10 TB | €20.80 |
| Hetzner Storage Box | BX41 | - | - | 20 TB | €40.60 |
| Hetzner Object Storage | prezzo 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)
- 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]
- 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.
- etcd: aggiungere il membro con
etcdctl member adde avviarlo con ETCD_INITIAL_CLUSTER_STATE=existing. Con quattro siti di database mantenere dispari il numero di membri etcd. [48] - Patroni si avvia come replica e replica automaticamente. Garage:
garage layout assigncon la nuova zona,garage layout apply. - 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.
- 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.
| Lacuna | Impatto | Soluzione 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 S3 | Nessuna 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 integrato | Il server non include un servizio di backup per database e blob. | pgBackRest e rclone (capitolo 09). |
| Nessuna read replica | Tutte 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 misurato | Le 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.
- RFC 5321 (SMTP): 4.1.4 EHLO, 4.5.4.1 retry, 5.1 MX: https://www.rfc-editor.org/rfc/rfc5321.html
- RFC 7208 (SPF): 4.6.4 limite di lookup DNS, 5.6 ip4/ip6: https://www.rfc-editor.org/rfc/rfc7208.html
- RFC 6376 (DKIM): 3.1 selettori: https://www.rfc-editor.org/rfc/rfc6376.html
- RFC 8461 (MTA-STS): 3.2, 4.1 pattern mx: https://www.rfc-editor.org/rfc/rfc8461.html
- RFC 7672 (DANE per SMTP): 3.1 TLSA 3 1 1: https://www.rfc-editor.org/rfc/rfc7672.html
- RFC 8314 (TLS implicito 465/993/995): https://www.rfc-editor.org/rfc/rfc8314.html
- RFC 2177 (IMAP IDLE, da rinnovare ogni 29 minuti): https://www.rfc-editor.org/rfc/rfc2177.html
- Google: linee guida per i mittenti di e-mail (DNS diretto e inverso): https://support.google.com/a/answer/81126
- Hetzner Docs: Floating IP: https://docs.hetzner.com/cloud/floating-ips/overview
- Hetzner Docs: prezzi IPv4: https://docs.hetzner.com/general/others/ipv4-pricing
- Hetzner Docs: IP di failover Robot: https://docs.hetzner.com/robot/dedicated-server/ip/failover
- Hetzner Cloud: piani cost-optimized: https://www.hetzner.com/cloud/cost-optimized/
- Hetzner Docs: FAQ sui cloud server (porte 25/465): https://docs.hetzner.com/cloud/servers/faq/
- Hetzner Docs: record PTR: https://docs.hetzner.com/networking/dns/record-types/ptr-record/
- Hetzner Storage Box (prezzi, protocolli): https://www.hetzner.com/storage/storage-box/
- Hetzner Object Storage (prezzi): https://www.hetzner.com/storage/object-storage/
- Hetzner Docs: panoramica Object Storage: https://docs.hetzner.com/storage/object-storage/overview
- Hetzner Docs: Object Lock e conservazione: https://docs.hetzner.com/storage/object-storage/howto-protect-objects/protect-object-lock-retention/
- Hetzner Docs: FAQ Load Balancer: https://docs.hetzner.com/cloud/load-balancers/faq
- Hetzner Docs: FAQ sulle reti: https://docs.hetzner.com/cloud/networks/faq
- Hetzner Docs: DNS (Console e API): https://docs.hetzner.com/networking/dns/overview/
- netcup: prezzi VPS G12.5: https://www.netcup.com/de/server/vps
- netcup: prezzi Root Server G12.5: https://www.netcup.com/de/server/root-server
- netcup Helpcenter: indirizzi IP e failover: https://www.netcup.com/en/helpcenter/documentation/server/ip
- netcup: estensioni server (prezzi degli IP di failover): https://www.netcup.com/de/server/kvm-server-erweiterungen
- netcup Helpcenter: firewall (policy "netcup Mail block"): https://www.netcup.com/en/helpcenter/documentation/server/firewall
- netcup Helpcenter: rete / rDNS: https://www.netcup.com/en/helpcenter/documentation/server/network-server
- netcup: Storage Space (NFS): https://www.netcup.com/en/server/storage-space
- netcup Helpcenter: DNS API: https://www.netcup.com/en/helpcenter/documentation/domain/our-api
- STRATO: prezzi VPS Linux: https://www.strato.de/server/linux-vserver/
- STRATO FAQ: reverse DNS per VPS: https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
- 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/
- Cloudflare: prezzo del componente aggiuntivo Load Balancing: https://www.cloudflare.com/plans/network-cdn/
- Cloudflare Docs: limitazioni del Load Balancing (intervallo di health check): https://developers.cloudflare.com/load-balancing/reference/limitations/
- Cloudflare Docs: record DNS del Load Balancing (MX/SRV solo Enterprise): https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
- Cloudflare Docs: modalità proxy (solo DNS): https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
- Cloudflare Docs: protocolli Spectrum per piano: https://developers.cloudflare.com/spectrum/protocols-per-plan/
- Cloudflare Docs: DNS TTL: https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/
- API Cloudflare: modificare un record DNS: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
- deSEC: domini e TTL minimo: https://desec.readthedocs.io/en/latest/dns/domains.html
- Akamai/Linode: IP failover (BGP, stesso data center): https://techdocs.akamai.com/cloud-computing/docs/configure-failover-on-a-compute-instance
- Akamai: prezzi NodeBalancer: https://www.akamai.com/cloud/pricing/nodebalancers
- Vultr: requisiti BGP: https://docs.vultr.com/configuring-bgp-on-vultr
- RIPE NCC: richiesta di un numero AS: https://www.ripe.net/manage-ips-and-asns/as-numbers/request-an-as-number/
- Proxmox VE: Cluster Manager (latenza corosync, QDevice): https://pve.proxmox.com/wiki/Cluster_Manager
- Proxmox Backup Server: datastore (incl. S3): https://pbs.proxmox.com/docs/storage.html
- WireGuard: https://www.wireguard.com/
- k3s: HA con etcd integrato: https://docs.k3s.io/datastore/ha-embedded
- k3s: cluster ibrido distribuito o multicloud: https://docs.k3s.io/networking/distributed-multicloud
- etcd: tuning (heartbeat, election timeout): https://etcd.io/docs/v3.5/tuning/
- Talos Linux: KubeSpan: https://docs.siderolabs.com/talos/v1.11/networking/kubespan
- CloudNativePG: replica cluster: https://cloudnative-pg.io/docs/current/replica_cluster
- Patroni: configurazione dinamica (ttl, loop_wait, retry_timeout): https://patroni.readthedocs.io/en/latest/dynamic_configuration.html
- Patroni: modalità di replica (synchronous_mode, strict): https://patroni.readthedocs.io/en/latest/replication_modes.html
- Patroni: modalità failsafe DCS: https://patroni.readthedocs.io/en/latest/dcs_failsafe_mode.html
- PostgreSQL 18: replica sincrona: https://www.postgresql.org/docs/current/warm-standby.html#SYNCHRONOUS-REPLICATION
- PostgreSQL 18: shared_buffers: https://www.postgresql.org/docs/current/runtime-config-resource.html
- pgBackRest (repository S3 e SFTP): https://pgbackrest.org/
- CISA: Data Backup Options (3-2-1): https://www.cisa.gov/sites/default/files/publications/data_backup_options.pdf
- Garage: pagina del progetto (multi-datacenter, 200 ms / 50 Mbps): https://garagehq.deuxfleurs.fr/
- Garage: configurazione (replication_factor, quorum): https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
- Garage: compatibilità S3 (senza Object Lock, senza versioning): https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/
- Npgsql: failover e bilanciamento del carico (multi-host): https://www.npgsql.org/doc/failover-and-load-balancing.html
- Mermaid 12.2.0 (MIT), incluso in questo sito: https://www.npmjs.com/package/mermaid