nextxch com alta disponibilidade em vários provedores
Quando um provedor inteiro fica offline, o e-mail deve continuar fluindo: sem anycast caro, sem balanceadores de carga corporativos, implantável a partir do Git. Este guia mostra três configurações em servidores de baixo custo na Hetzner, netcup e STRATO, explica segundo a segundo o que acontece durante uma interrupção e calcula recursos e custos. Cada afirmação sobre provedores tem link para uma fonte com a data da verificação, e cada afirmação sobre o nextxch indica o arquivo de origem no código.
01 · O que o nextxch oferece para alta disponibilidade (verificado no código)
O nextxch é compatível com active-active: cada nó aceita SMTP, IMAP, JMAP, ActiveSync e HTTPS, não há nó passivo nem sticky session. O PostgreSQL mantém o estado compartilhado; as próprias mensagens são armazenadas como objetos .eml endereçados por SHA-256 no blob store.
| Capacidade | Como | Fonte no código |
|---|---|---|
| Backplane do cluster sem Redis | PostgreSQL LISTEN/NOTIFY (canal nextxch_cluster_events) para eventos como novo e-mail, flags, expunge, fila; tabelas UNLOGGED cluster_ephemeral e cluster_nodes; heartbeat a cada 15 s, nós removidos após 60 s sem heartbeat | src/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs |
| Tarefas do líder | pg_try_advisory_lock: exatamente um nó renova os certificados ACME, envia o heartbeat da licença, sincroniza o LDAP e envia lembretes de calendário | AcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs |
| Fila SMTP entre nós | FOR UPDATE SKIP LOCKED: cada nó reivindica itens diferentes da fila, sem entrega duplicada | src/Nextxch.Storage/Postgres/PostgresMailStore.cs |
| Bancos de dados com suporte a cluster | PostgreSQL (padrão para clusters) e SQL Server (sp_getapplock); SQLite apenas em nó único | src/Nextxch.Storage/StorageServiceCollectionExtensions.cs |
| Armazenamento de blobs | FileSystem, S3 (cliente SigV4 próprio com Content-MD5) ou híbrido (cache NVMe local + S3). Modos de Object Lock Normal, Governance, Compliance e legal hold | src/Nextxch.Storage/Blobs/S3BlobStore.cs, HybridBlobStore.cs |
| Certificados e DKIM | Certificados ACME e chaves DKIM ficam no banco de dados; os desafios HTTP-01 são compartilhados no cluster, de modo que qualquer nó pode responder; a chave do certificado é mantida na renovação (DANE 3 1 1 continua válido) | src/Nextxch.Security/Tls/AcmeCertificateManager.cs |
| Endpoints de integridade | /health/live (sem BD), /health/ready (BD acessível, caso contrário 503), /health (visão geral incl. peers do cluster) | src/Nextxch.Server/Program.cs |
| IP real do cliente | Protocolo PROXY v1/v2 para SMTP, IMAP, ManageSieve (Cluster:EnableProxyProtocol) e X-Forwarded-For para HTTP a partir de TrustedProxyCidrs | src/Nextxch.Core/Abstractions/IClusterCoordinator.cs |
| Implantar artefatos | docker-compose.yml, manifestos Kubernetes (3 réplicas, HPA de 2 a 10, PDB, probes), Helm chart, deb/rpm, Windows MSI | deploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/ |
| Licença | São contadas as caixas de correio ativas, não nós ou pods; o estado da licença é compartilhado via cluster_kv | src/Nextxch.Server/Services/ClusterLicenseService.cs, Preços |
Portas por nó: 25, 465, 587 (SMTP), 143, 993 (IMAP), 4190 (ManageSieve), 80/443 (HTTPS, ACME, webmail, ActiveSync, JMAP). POP3 110/995 vem desativado por padrão (Pop3:Enabled=false em Pop3Settings.cs).
/health/ready retorna 503 e o nó não consegue verificar destinatários. Alta disponibilidade entre provedores significa, portanto, replicar o PostgreSQL e o armazenamento de blobs entre sites e alternar automaticamente. É isso que as três configurações constroem.02 · Regras específicas de e-mail que facilitam a alta disponibilidade
Entrada: o MX tem failover integrado
Os remetentes ordenam os registros MX por preferência e devem tentar todos os endereços; com preferência igual, eles devem randomizar. [1] Se um destino estiver inacessível, o intervalo entre tentativas deve ser de no mínimo 30 minutos e o tempo até desistir de no mínimo 4 a 5 dias. [1]
Saída: SPF e PTR por IP
O SPF deve listar todos os IPs de saída de todos os sites; ip4/ip6 não contam para o limite de 10 lookups DNS. [2] O Gmail exige DNS direto e reverso válidos; o PTR é definido separadamente em cada provedor. [8][14][27][31]
DKIM, MTA-STS, DANE
As chaves DKIM ficam no banco de dados do cluster, um único conjunto de DNS atende todos os nós; seletores permitem várias chaves em paralelo. [3] A política MTA-STS deve nomear todos os hosts MX; curingas cobrem apenas o rótulo mais à esquerda. [4] O DANE precisa de um registro TLSA por host MX, 3 1 1 é recomendado, e de uma zona assinada com DNSSEC. [5]
Clientes: IMAP, submissão, HTTPS
Clientes de e-mail não têm failover no estilo MX. Eles se conectam a um nome (aqui mail.example.com) cujos registros A são mantidos por um job de verificação de integridade. Conexões IDLE são renovadas pelo menos a cada 29 minutos e então chegam a um nó íntegro. [7][6]
; Zone example.com (TTL in seconds). IPs are documentation ranges: replace them.
; Site A = Hetzner 192.0.2.10, site B = netcup 198.51.100.20, site C (medium setup) = 203.0.113.30
; Inbound SMTP: equal preference, senders MUST randomize (RFC 5321 section 5.1).
example.com. 3600 IN MX 10 mx1.example.com.
example.com. 3600 IN MX 10 mx2.example.com.
; Static per-site names: never touched by the failover job (stable FCrDNS for PTR).
mx1.example.com. 3600 IN A 192.0.2.10
mx2.example.com. 3600 IN A 198.51.100.20
; Client name (IMAP, submission, HTTPS, webmail, ActiveSync, autodiscover):
; short TTL, kept up to date by the health-check job. Start with all healthy nodes.
mail.example.com. 60 IN A 192.0.2.10
mail.example.com. 60 IN A 198.51.100.20
; SPF: every outbound IP of every site. ip4/ip6 do not count against the 10-lookup limit (RFC 7208 4.6.4).
example.com. 3600 IN TXT "v=spf1 ip4:192.0.2.10 ip4:198.51.100.20 ip4:203.0.113.30 -all"
; DKIM: nextxch keeps the keys in the cluster database, so one record set serves all nodes.
; Publish the selectors shown under /admin/ (Domains > DKIM).
; MTA-STS: the policy must list every MX host (see the note on gaps).
_mta-sts.example.com. 3600 IN TXT "v=STSv1; id=20261102"
; DANE (optional, needs a DNSSEC-signed zone): one TLSA record per MX host.
; nextxch reuses the leaf key on renewal, so "3 1 1" stays stable.
_25._tcp.mx1.example.com. 3600 IN TLSA 3 1 1 <sha256-of-spki>
_25._tcp.mx2.example.com. 3600 IN TLSA 3 1 1 <sha256-of-spki>
; PTR (set in the provider console, not in this zone):
; 192.0.2.10 -> mx1.example.com (Hetzner Console / Robot)
; 198.51.100.20 -> mx2.example.com (netcup SCP)
Smtp:HeloHostname na configuração ou em /admin/ em "Hostname, IPv6 & portas" (nome EHLO por nó, com verificação de PTR por nó). Ele é usado no banner 220, na resposta EHLO e no EHLO de saída. Aponte o PTR de cada IP para seu nome fixo mx1/mx2; o DNS advisor mostra um registro SPF de HELO por nó. De qualquer forma, a RFC 5321, seção 4.1.4, proíbe recusar uma mensagem apenas por causa de um nome EHLO divergente. [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 · Como os clientes são redirecionados: comparação de opções
IPs de failover de provedores só ajudam dentro de um mesmo provedor. Para a queda de um provedor inteiro, o DNS continua sendo a ferramenta simples e barata.
| Opção | Escopo | Custo | Avaliação |
|---|---|---|---|
| Vários MX com a mesma preferência | entre provedores | 0 € | Obrigatório para e-mail de entrada, padronizado [1] |
| Job próprio de verificação de integridade + API de DNS (Cloudflare, Hetzner, deSEC, netcup) | entre provedores | 0 € | Recomendado. Cloudflare: TTL a partir de 60 s (Enterprise 30 s), auto = 300 s. Hetzner DNS: gratuito, TTL a partir de 60 s, API no Hetzner Console. deSEC: TTL mínimo por domínio, por exemplo 3600 s, menor apenas sob solicitação. netcup: existe a CCP DNS API, TTL mínimo não documentado. [38][39][21][40][29] |
| Cloudflare Load Balancing (somente DNS) | entre provedores | a partir de US$ 5/mês | Health checks (incl. TCP, SMTP) a cada 15 s fora do Enterprise; para A/AAAA/CNAME, MX/SRV somente Enterprise. Substitui o job próprio para mail.example.com. [33][34][35][36] |
| Cloudflare Spectrum | entre provedores | Enterprise | Portas TCP arbitrárias (25, 465, 587, 993) somente como complemento Enterprise; Pro/Business apenas SSH, Minecraft, RDP. [37] |
| Hetzner Floating IP (Cloud) | um projeto, uma zona de rede | IPv4 € 3,00, IPv6 € 1,00/mês | Somente dentro da Hetzner [9] |
| IP de failover da Hetzner (Robot, dedicado) | Data centers da Hetzner | € 4,20/mês + € 4,90 de setup | Troca via Robot API em 90 a 110 s, somente dentro da Hetzner [11] |
| IP de failover da netcup | mesmo local da netcup | € 5,00/mês com IVA | Redirecionamento no SCP a qualquer momento e gratuito, somente dentro da netcup [24][25] |
| Compartilhamento de IP Akamai/Linode | um data center, dois Linodes | a partir de USD 5 (Nanode) | Baseado em BGP (lelastic), somente no mesmo data center; NodeBalancer Basic USD 10, TCP/HTTP [41][42] |
| Anycast / BGP próprio | global | alto | ASN próprio (contrato RIPE via associação ou LIR patrocinador) mais espaço de endereços próprio (na Vultr, no mínimo /24 IPv4 ou /48 IPv6). Desproporcional para a maioria das instalações do nextxch. [43][44] |
04 · Configuração S: dois sites mais testemunha
Para pequenas e médias empresas. O site A (por exemplo, Hetzner Cloud) e o site B (por exemplo, netcup VPS) executam cada um o nextxch, PostgreSQL sob Patroni, um membro etcd e uma zona Garage. Uma pequena testemunha C em um terceiro provedor (por exemplo, STRATO) mantém o terceiro membro etcd, a terceira cópia Garage e o verificador de failover de DNS. Os três são interligados com 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
Por que Garage como blob store?
Um bucket S3 em um único provedor desapareceria junto com esse provedor, e o nextxch atualmente só consegue gravar em um destino S3. O Garage é um armazenamento compatível com S3 (AGPL-3.0) criado para vários data centers pela internet (requisito do projeto: no máximo 200 ms de latência, no mínimo 50 Mbit/s). Com replication_factor = 3, há uma cópia por zona; leituras e gravações continuam enquanto apenas uma zona falhar (quórum de gravação 2). Com apenas duas zonas, a perda de uma zona deixaria o armazenamento somente leitura, e é por isso que a testemunha também mantém uma cópia completa. [60][61]
Passo a passo
- Contratar servidores: A = Hetzner Cloud (as portas 25/465 são bloqueadas por padrão; solicite o desbloqueio após um mês e a primeira fatura paga, a 587 está aberta), B = netcup VPS (remova a política de firewall "netcup Mail block" no SCP), C = STRATO VPS como testemunha (a testemunha não precisa da porta 25). [13][26]
- Configurar PTR: IP A para mx1.example.com (Hetzner Console), IP B para mx2.example.com (netcup SCP). [14][27]
- Instalação base via cloud-init (Debian 13): usuário de deploy, Docker, WireGuard, sem login por senha.
- Malha WireGuard 10.88.0.0/24 entre A, B e C; abra UDP 51820 apenas para os IPs dos peers. [47]
- etcd nos três (election timeout de no mínimo 10 × RTT), depois Patroni + PostgreSQL 18 em A e B. [50][53]
- Garage nos três, aplique um layout com três zonas, crie o bucket e a chave. [61]
- nextxch via Docker Compose em A e B (Cluster:Enabled=true, string de conexão multi-host, S3 apontando para o Garage local). Em /admin/, defina o nome de host mail.example.com e emita o certificado ACME para mail, mx1, mx2.
- DNS como no capítulo 02, verifique a lista MX do MTA-STS em /admin/, ative o failover de DNS em /admin/ em Domínios (somente o líder do cluster atua) ou, alternativamente, o script sob um lock do etcd.
- Backups como no capítulo 09, e teste uma restauração. Depois, ensaie uma falha: desligue o site A à força e siga o capítulo 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:
A connection string informa os dois hosts PostgreSQL com Target Session Attributes=primary. O nextxch cria suas conexões via NpgsqlDataSource.Create (PostgresMailStore.cs); com vários hosts, o Npgsql monta automaticamente uma fonte de dados multi-host e se conecta apenas ao primário atual, verificando o estado novamente a cada Host Recheck Seconds (padrão 10 s). Após um failover do Patroni, os nós se reconectam sem nenhuma alteração de configuração; a conexão LISTEN do cluster tenta novamente a cada 2 s (PostgresClusterCoordinator.cs). [63]
Failover automático de DNS para mail.example.com
O nextxch inclui um agente de failover de DNS (opt-in, desativado por padrão; /admin/ em Domínios, cartão "DNS failover"). Cada nó verifica a cada 30 s o banner SMTP na porta 25 e o HTTPS /health de todos os nós por meio do IP público. Somente o líder do cluster age: ele mantém um advisory lock do PostgreSQL, combina a visão de todos os nós por maioria e grava os registros A/AAAA dos nomes configurados pela API do Hetzner DNS (Cloud API) ou da Cloudflare. Histerese: após N rodadas com falha, um nó sai do DNS; após M rodadas saudáveis, ele retorna; o último nó nunca é removido. Cada alteração fica no log de eventos, e a regra de alerta "DNS failover: node taken out" notifica pelos alertas de operação. O token da API é criptografado com o keyring do servidor e nunca mais é exibido.
# 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 na string de conexão (capítulo 04 acima), um lado isolado não alcança nenhum primário, não obtém o lock e permanece passivo. A votação por maioria entre todos os observadores impede que um único nó com uma visão defeituosa remova um nó íntegro.Alternativa: script sob um lock do etcd
Para provedores de DNS sem conector integrado (por exemplo, deSEC), ou quando o etcd deve tomar a decisão, o script continua sendo uma alternativa equivalente. Não execute o agente e o script para os mesmos nomes ao mesmo tempo. O script verifica /health/ready de cada nó pelo seu IP público a cada 20 s e ajusta os registros A pela API da Cloudflare (PATCH/POST/DELETE em dns_records). Ele é executado em cada site sob etcdctl lock: apenas uma cópia atua, e somente no lado com maioria do etcd. Um site isolado, portanto, não consegue mais alterar o DNS. Para Hetzner DNS ou deSEC, mudam apenas as três chamadas 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 · Configuração M: três sites completos (Proxmox ou VMs simples)
Como a configuração S, mas os três sites executam o nextxch com uma réplica PostgreSQL. Quando um site falha, restam dois bancos de dados e dois nós nextxch, e com synchronous_mode até a replicação síncrona é mantida. Três registros MX com a mesma preferência distribuem a carga de entrada.
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 a passo
- Três VMs (ou guests Proxmox) em três provedores, mesma versão do Debian, PTR para mx1/mx2/mx3 em cada uma.
- Inventário Ansible com três hosts, um playbook para todos (WireGuard, etcd, Patroni, Garage, nextxch).
- Configuração do Patroni do setup S com um terceiro host; synchronous_mode: on, com três bancos de dados opcionalmente com quórum. [54]
- String de conexão com três hosts, layout Garage com três zonas, três registros MX, três IPs no SPF e na lista de nós do failover de DNS.
- Proxmox Backup Server opcional para imagens de VM: datastore local ou S3 (backend S3 com suporte oficial desde o PBS 4.2); a Proxmox não documenta compartilhamentos de rede, como uma Storage Box via CIFS, como tipo de datastore. [46]
# inventory/ha.yml: three sites, one VM (or Proxmox guest) per site
all:
vars:
ansible_user: deploy
wg_network: 10.88.0.0/24
pg_major: 18
children:
nextxch_sites:
hosts:
hetzner-a: { ansible_host: 192.0.2.10, wg_ip: 10.88.0.1, garage_zone: hetzner }
netcup-b: { ansible_host: 198.51.100.20, wg_ip: 10.88.0.2, garage_zone: netcup }
third-c: { ansible_host: 203.0.113.30, wg_ip: 10.88.0.3, garage_zone: third }
# site.yml: idempotent base for every site (same playbook for Hetzner, netcup, Strato ...)
- hosts: nextxch_sites
become: true
tasks:
- name: Base packages (Debian 13)
ansible.builtin.apt:
name: [wireguard, etcd-server, etcd-client, patroni, pgbackrest, docker.io, docker-compose-v2, jq, curl]
update_cache: true
# PostgreSQL 18 itself comes from the PGDG apt repository (apt.postgresql.org),
# Garage as the static binary or the dxflrs/garage image.
- name: WireGuard config
ansible.builtin.template: { src: wg0.conf.j2, dest: /etc/wireguard/wg0.conf, mode: "0600" }
notify: restart wg
- name: etcd, Patroni and Garage config
ansible.builtin.template: { src: "{{ item.src }}", dest: "{{ item.dest }}", mode: "0640" }
loop:
- { src: etcd.env.j2, dest: /etc/default/etcd }
- { src: patroni.yml.j2, dest: /etc/patroni/config.yml }
- { src: garage.toml.j2, dest: /etc/garage.toml }
- name: nextxch compose file
ansible.builtin.template: { src: docker-compose.yml.j2, dest: /opt/nextxch/docker-compose.yml }
- name: Start nextxch
ansible.builtin.command: docker compose up -d
args: { chdir: /opt/nextxch }
changed_when: false
handlers:
- name: restart wg
ansible.builtin.service: { name: wg-quick@wg0, state: restarted }
06 · Setup L: Kubernetes em três provedores
Para muitas caixas de correio e escalonamento horizontal. Importante primeiro: o k3s explicitamente não oferece suporte a etcd embutido em um cluster distribuído entre nuvens; os nós de servidor devem ficar em um único local. Portanto: um cluster Kubernetes por provedor (k3s ou Talos em cada um), cada um executando uma implantação do nextxch a partir do Helm chart fornecido. A camada de dados (Patroni + etcd + Garage) roda em VMs próprias via WireGuard, como na configuração M, e é usada pelos três clusters. [49][48]
flowchart TB
DNS["DNS: MX per provider LB, mail.example.com with health checks"]
subgraph K1["k3s/Talos cluster Hetzner"]
L1["Load balancer"] --> P1["nextxch pods (HPA)"]
end
subgraph K2["k3s/Talos cluster netcup"]
L2["Load balancer"] --> P2["nextxch pods (HPA)"]
end
subgraph K3["k3s/Talos cluster STRATO"]
L3["Load balancer"] --> P3["nextxch pods (HPA)"]
end
DNS --> L1
DNS --> L2
DNS --> L3
subgraph D["Data layer on VMs, WireGuard"]
PG[("Patroni: primary + 2 replicas")]
GA["Garage: 3 zones"]
ET["etcd: 3 members"]
end
P1 --> PG
P2 --> PG
P3 --> PG
P1 --> GA
P2 --> GA
P3 --> GA
Por que não CloudNativePG entre clusters?
O CloudNativePG pode executar clusters de réplica em outros clusters Kubernetes, mas a promoção e o rebaixamento entre clusters são declarativos (campo primary, promotionToken), ou seja, não automáticos. Para failover automático entre provedores, o Patroni com etcd em três sites é a solução mais direta. [52]
Um cluster abrangendo todos os provedores?
O Talos oferece o KubeSpan, uma malha completa WireGuard entre sites, e o k3s um backend flannel wireguard-native. O plano de controle (etcd), porém, é sensível à latência da WAN. Vários clusters pequenos são mais simples de operar e falham de forma independente. [51][49][50]
Passo a passo
- Construa a camada de dados como na configuração M (três VMs com Patroni, etcd, Garage).
- Um cluster k3s ou Talos por provedor; conexão de rede com a camada de dados via WireGuard (ou KubeSpan).
- Portas de entrada: serviço do tipo LoadBalancer com
externalTrafficPolicy: Local(como no repositório), para que o IP do remetente seja preservado para SPF/DMARC; com um load balancer que fala o protocolo PROXY, useCluster__EnableProxyProtocol=true. O Hetzner Cloud Load Balancer suporta o protocolo PROXY e qualquer serviço TCP. [19] - Porta 25 de saída: egress dos pods por um IP fixo com PTR e entrada SPF correspondentes (egress gateway, conforme descrito em deploy/kubernetes/README.md).
- Valores do Helm por provedor, rollout via pipeline um após o outro (capítulo 07). HPA (2 a 10 pods a 70% de CPU) e PodDisruptionBudget vêm com o repositório.
# 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 · Implantar e atualizar a partir do Git
Tudo o que é importante é um arquivo no repositório: arquivo compose ou Helm values, inventário Ansible, script de failover de DNS. O pipeline sempre implanta apenas um site e espera até que /health/ready volte a informar "ready" antes de prosseguir. Segredos (licença, senhas, tokens de API, chaves SSH) ficam apenas nos repositórios de segredos do GitHub/GitLab ou no .env do servidor, nunca no repositório.
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 comenta o pull request com o plano, config apply --prune --yes o aplica após o merge. Guia com arquivos prontos de GitHub Actions e GitLab CI: Configuração como código (GitOps).08 · Cenários de falha: o que acontece exatamente
A Hetzner (site A, primário atual) fica 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 | O que acontece | O que os usuários percebem |
|---|---|---|
| 0 s | A caiu. E-mails de entrada para mx1 falham, os remetentes usam mx2 (mesma preferência) ou tentam novamente mais tarde. [1] | Os clientes em A perdem a conexão. |
| até cerca de 30 s | A chave de líder de A no etcd expira após ttl = 30 s; B e C detêm a maioria. [53] | O nó B retorna 503 em /health/ready, as gravações aguardam. |
| cerca de 30 a 60 s | O Patroni promove B (loop_wait 10 s). O nextxch em B encontra o novo primário pela string de conexão multi-host (nova verificação em 5 a 10 s). [63] | B volta a aceitar e-mails. |
| cerca de 60 a 90 s | O failover de DNS (agente: 3 rodadas com falha × 30 s, script: 3 × 20 s) remove o IP A de mail.example.com. | Novas conexões vão para B assim que o TTL (60 s) expira. |
| 1 a 5 min | Resolvedores com TTLs mínimos próprios e clientes com cache de DNS acompanham depois. O Garage continua com as zonas B e C (quórum 2 de 3). [61] | Os clientes se reconectam; as sessões do webmail permanecem válidas (sessões assinadas e sem estado). |
RPO
Com synchronous_mode, o PostgreSQL só confirma um commit depois que o segundo site o tiver; uma mensagem confirmada com 250 OK passa então a existir em dois sites. O preço: cada commit espera pelo menos uma ida e volta entre os sites. Sem synchronous_mode, os últimos segundos antes da falha podem faltar. Com synchronous_mode_strict: false, o Patroni volta ao modo assíncrono quando não resta nenhuma réplica, de modo que os e-mails continuam sendo aceitos. [56][54]
RTO
Banco de dados cerca de 30 a 60 s (padrões do Patroni), troca de DNS cerca de 1 min mais o TTL, e-mail de entrada sem espera via o segundo MX ou a nova tentativa do remetente. Esses tempos são derivados dos padrões, não medidos: verifique em seu próprio teste (desligue um site abruptamente).
netcup (site B, réplica) fica offline
O primário em A continua rodando. Sem um parceiro síncrono, o Patroni passa para o modo assíncrono (strict = false), o job de failover remove o IP B e o e-mail de entrada flui via mx1. O Garage continua com A e C.
Apenas o link entre A e B está fora do ar
O lado que ainda alcança a testemunha C mantém a maioria do etcd e permanece ou se torna primário. O outro lado perde sua maioria, o Patroni rebaixa um primário ali (failsafe_mode só se aplica se ele ainda alcançar todos os membros) e o failover de DNS não atua ali: o agente integrado não alcança nenhum primário e não obtém o leader lock, o script não obtém o lock do etcd. Não ocorre 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 volta, B continua primário. O Patroni traz A de volta como réplica com pg_rewind (use_pg_rewind: true), o Garage ressincroniza os objetos ausentes e o job de failover adiciona novamente o IP A assim que /health/ready informar "ready" ali. Não é necessário mover o primário de volta; se desejado, faça isso de forma planejada com patronictl switchover em um horário tranquilo.
09 · Backup, restauração e GoBD
A replicação protege contra falhas, não contra erros: uma caixa de correio excluída por engano desaparece em todos os sites ao mesmo tempo. Por isso, complemente com backups seguindo a regra 3-2-1: três cópias, dois tipos de mídia, uma fora do local. [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 com arquivamento contínuo de WAL (point-in-time recovery). Repositório 1 em uma Hetzner Storage Box via SFTP (o pgBackRest suporta SFTP desde a 2.46; a Storage Box oferece SFTP, SCP, rsync, BorgBackup, Restic, Rclone, SMB, WebDAV), repositório 2 em S3 em outro provedor. Ambos criptografados. [58][15]
- Blobs: os objetos .eml são nomeados pelo SHA-256 e nunca modificados.
rclone copy(não sync, portanto nunca exclui) basta como cópia de hora em hora. - Arquivo GoBD: o Hetzner Object Storage suporta Object Lock nos modos GOVERNANCE e COMPLIANCE, além de versionamento; o Object Lock precisa ser ativado na criação do bucket. Crie o bucket com uma retenção padrão (compliance); então cada objeto copiado é bloqueado automaticamente. Como alternativa, deixe o nextxch gravar diretamente nesse bucket (backend S3 ou híbrido), mas nesse caso a disponibilidade dos blobs depende da Hetzner. Nesse caso, o nextxch cria na inicialização um bucket ausente com Object Lock assim que um modo WORM estiver configurado (ou
Storage:S3:ObjectLockEnabled); "Verificar bucket S3" no arquivo GoBD mostra a acessibilidade, o versionamento, o modo de Object Lock e a retenção padrão. [18][17] - Testar restaurações: pelo menos trimestralmente, restaure o pgBackRest em uma VM de teste e abra amostras da cópia de blobs.
# /etc/pgbackrest/pgbackrest.conf on every PostgreSQL site.
# Repo 1: Hetzner Storage Box over SFTP (pgBackRest 2.46+), repo 2: S3 at another provider.
[global]
repo1-type=sftp
repo1-path=/pgbackrest
repo1-sftp-host=uXXXXXX.your-storagebox.de
repo1-sftp-host-port=23
repo1-sftp-host-user=uXXXXXX
repo1-sftp-private-key-file=/var/lib/postgresql/.ssh/id_ed25519
repo1-sftp-host-key-hash-type=sha256
repo1-retention-full=2
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<long random secret, stored outside the repo>
repo2-type=s3
repo2-path=/pgbackrest
repo2-s3-endpoint=<s3 endpoint of the second provider>
repo2-s3-bucket=nextxch-pg-backup
repo2-s3-region=<region>
repo2-s3-uri-style=path
repo2-retention-full=4
repo2-cipher-type=aes-256-cbc
repo2-cipher-pass=<second secret>
archive-async=y
process-max=2
start-fast=y
[nextxch]
pg1-path=/var/lib/postgresql/18/main
pg1-port=5432
# Weekly full, daily differential (cron on every node; pgBackRest runs only where it can,
# use --repo and the "backup-standby" option to take backups from a replica).
# 0 2 * * 0 pgbackrest --stanza=nextxch --type=full backup
# 0 2 * * 1-6 pgbackrest --stanza=nextxch --type=diff backup
# Blob copy (all .eml objects) from Garage to an independent target, every hour.
# ~/.config/rclone/rclone.conf (keys are placeholders)
[garage]
type = s3
provider = Other
endpoint = http://127.0.0.1:3900
region = garage
access_key_id = <garage key>
secret_access_key = <garage secret>
[archive]
type = s3
provider = Other
endpoint = <object storage endpoint>
access_key_id = <key>
secret_access_key = <secret>
# Content-addressed (SHA-256 names, never modified): "copy" is enough and never deletes.
# rclone copy garage:nextxch archive:nextxch-archive --immutable --transfers 8 --checksum
# For a GoBD copy create the target bucket WITH Object Lock and a default retention
# (COMPLIANCE, e.g. 10 years) so every copied object is locked automatically.
10 · Escalonamento: usuários, armazenamento, pods
flowchart LR S1["Start: 2 sites + witness"] --> S2["More mailboxes: add nextxch nodes per site"] S2 --> S3["DB grows: scale PostgreSQL VMs vertically (RAM, NVMe)"] S3 --> S4["More storage: add Garage nodes per zone"] S4 --> S5["Many mailboxes: Kubernetes per provider, HPA"]
| Componente | Escala | Observação |
|---|---|---|
| Nós / pods do nextxch | horizontalmente | Stateless; mais nós por site ou mais pods (HPA). Não são necessárias sticky sessions. |
| Primário PostgreSQL | verticalmente | Todas as gravações vão para um único primário. Mais RAM e discos mais rápidos (IOPS) em vez de mais nós; valor inicial de shared_buffers: 25% da RAM. O nextxch não usa réplicas de leitura. [57] |
| Armazenamento de blobs | horizontalmente | Mais nós Garage por zona com capacidade (garage layout assign -c); os dados são rebalanceados. A deduplicação SHA-256 e a compactação Zstd reduzem a necessidade. |
| SMTP | via DNS | Mais registros MX com a mesma preferência distribuem a carga; a fila de saída se distribui entre todos os nós via SKIP LOCKED. |
| IMAP | via DNS / LB | Conexões IDLE de longa duração permanecem em seu nó; e-mails novos de outros nós chegam via LISTEN/NOTIFY. Após adicionar um nó, a carga se distribui somente à medida que os clientes se reconectam. [7] |
| HTTPS (webmail, ActiveSync, JMAP) | via DNS / LB | Sessões HMAC sem estado; qualquer requisição pode chegar a qualquer nó. |
11 · Custos: onde economizar e onde não
Salvar
- Planos com vCPU compartilhada para o nextxch e a testemunha; núcleos dedicados somente quando a carga de CPU do banco de dados indicar.
- Prazo de 12 meses na netcup (13 % abaixo do preço mensal, segundo a página de preços). [22]
- Failover de DNS com o agente integrado (ou seu próprio script) em vez de um load balancer pago.
- Compressão Zstd (Storage:Compression) e deduplicação SHA-256 para blobs.
Onde não economizar
- RAM dos nós PostgreSQL: tudo o que não estiver em cache custa IO de disco a cada visualização de pasta.
- IOPS de disco para o PostgreSQL (NVMe); as gravações do WAL determinam a latência de cada entrega.
- O terceiro site: sem ele, não há failover automático seguro.
- Backups testados em um destino independente.
Preços armazenados (líquidos por mês, situação em 2 de novembro de 2026). netcup e STRATO listam preços brutos incl. 19 % de IVA, aqui divididos por 1,19. Para a Hetzner, são acrescentados € 0,50 pelo IPv4 primário. Na data da verificação, a Hetzner exibia todos os planos de nuvem como "currently unavailable"; não foi possível determinar se era questão de estoque ou de exibição. O preço dos volumes da Hetzner não pôde ser lido nas páginas oficiais e, por isso, não consta na calculadora. Os preços mudam: confira com o provedor antes de contratar. [12][10][22][23][30][15][16]
| Provedor | Plano | vCPU | RAM | Disco | € líquido / mês |
|---|---|---|---|---|---|
| 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 | preço base incl. 1 TB de armazenamento + 1 TB de tráfego de saída | - | - | 1 TB | €6.49 |
12 · Calculadora: recursos e custos
Executado inteiramente no navegador, sem serviços externos. As premissas são listadas abertamente abaixo do resultado.
Premissas (não medidas, confira com os seus próprios números)
- Volume de e-mails = caixas de correio × tamanho médio × (1 + crescimento)^anos. Sem dedução por deduplicação ou compactação (conservador).
- Tamanho do PostgreSQL = 3 % do volume de e-mails (metadados, flags, índice de texto completo), no mínimo 2 GB. O valor real é obtido com
SELECT pg_database_size('nextxch'). - RAM para PostgreSQL = 25% do tamanho do BD, no mínimo 2 GB, arredondado para 2/4/8/16/32/64/96/128 GB. Disco para PostgreSQL = 2 × tamanho do BD (dados, WAL, folga).
- Processo do nextxch: 1 GB + 1 GB a cada 1.000 caixas de correio iniciadas (o limite do Kubernetes no repositório é 1 GiB por pod). etcd + Garage + sistema operacional: 1 GB. Testemunha: 2 GB de RAM. Sistema operacional: 20 GB de disco.
- Garage replication_factor 3: cada zona (site ou testemunha) armazena o volume completo de e-mails.
- Setup L: pelo menos 2 pods por provedor, 1 pod a cada 1.500 caixas de correio, workers com 8 GB; a VM de dados contém PostgreSQL, etcd e a zona Garage.
- Necessidade de backup = 3 × tamanho do BD (2 backups completos + WAL) + volume de e-mails; meta: a menor Hetzner Storage Box adequada.
- Preços de licença do catálogo de preços atual deste site (o preço da faixa vale para todas as caixas de correio, preço anual / 12). A Cloudflare é exibida em USD e não é somada ao total em euros.
13 · Adicionar outro provedor (exemplo STRATO)
- Antes de contratar: é possível definir o PTR? STRATO: DNS reverso para VPS no login do cliente, em domínios; antes é preciso existir um registro A/AAAA, e leva até 24 h. Porta 25: para servidores root, a STRATO documenta a saída como bloqueada, com desbloqueio via suporte; para VPS, nenhuma regra está documentada. A STRATO não oferece IP de failover. [31][32][30]
- Adicione o novo host ao inventário do Ansible com seu próprio IP WireGuard e zona Garage, adicione o peer em todos os sites e execute o playbook.
- etcd: adicione o membro com
etcdctl member adde inicie-o com ETCD_INITIAL_CLUSTER_STATE=existing. Com quatro sites de banco de dados, mantenha ímpar o número de membros etcd. [48] - O Patroni inicia como réplica e replica automaticamente. Garage:
garage layout assigncom a nova zona,garage layout apply. - DNS: adicione o IP ao SPF, um novo MX com a mesma prioridade, o nó à lista de failover de DNS (agente ou script), o registro TLSA para o novo nome MX, o nome EHLO e o PTR do novo nó, e amplie o certificado com o nome. A política MTA-STS incorpora o novo MX automaticamente (lista MX automática); em seguida, publique o novo valor TXT _mta-sts pelo advisor de DNS.
- Adicione o site à matriz do pipeline e teste uma vez a queda do novo site.
14 · Limites e lacunas (com honestidade)
Encontrado no código durante a elaboração deste guia. Quatro lacunas já foram fechadas: MTA-STS com vários nomes MX, nome EHLO por nó, bucket de Object Lock criado automaticamente e o agente de failover de DNS integrado. O guia contorna o restante conforme descrito.
| Lacuna | Impacto | Solução alternativa |
|---|---|---|
| Agente de failover de DNS (fechado) | Integrado, opt-in: health checks a partir de cada nó; apenas o líder do cluster altera A/AAAA via Hetzner DNS ou Cloudflare. | Outros provedores de DNS (por exemplo, deSEC): script sob lock do etcd (capítulo 04). |
| MTA-STS com vários MX (resolvido) | A política por domínio lista todos os nomes MX (DNS MX, nomes de servidor e de nó, ou uma lista própria com curingas); o id da política acompanha a lista. | Após cada alteração de MX, publique o valor TXT _mta-sts do DNS advisor. |
| Nome EHLO por nó (resolvido) | Smtp:HeloHostname ou uma configuração por nó em /admin/, com verificação de PTR por nó; o hostname do servidor permanece válido para todo o cluster. | - |
| Apenas um destino S3 | Sem gravação dupla em dois armazenamentos S3 de provedores diferentes; os recursos de Object Lock exigem um S3 com Object Lock. | Garage em três sites mais rclone copy para um bucket com Object Lock. |
| Bucket com Object Lock (fechado) | Com um modo WORM configurado, o servidor cria na inicialização um bucket ausente com Object Lock. Um bucket existente sem Object Lock é sinalizado como aviso e alerta, porque o Object Lock só pode ser ativado na criação. [18] | Verifique a qualquer momento com "Verificar bucket S3" no arquivo GoBD. |
| Sem backup integrado | Não há serviço de backup para banco de dados e blobs no servidor. | pgBackRest e rclone (capítulo 09). |
| Sem réplicas de leitura | Todas as consultas vão para o primário; réplicas de leitura constam no plano de desenvolvimento apenas como "sob consulta". | Escale o primário PostgreSQL verticalmente. |
| Nenhum benchmark de dimensionamento medido | Os tamanhos de RAM e de banco de dados na calculadora são estimativas. | Substitua pelos seus próprios valores medidos. |
Perguntas frequentes
O nextxch pode rodar em ativo-ativo em vários provedores?
Sim. Com Cluster:Enabled=true e PostgreSQL, qualquer número de nós nextxch compartilha o estado via LISTEN/NOTIFY, tabelas UNLOGGED e advisory locks. Todos os nós devem alcançar o mesmo primário PostgreSQL e o mesmo armazenamento de blobs S3; entre provedores, isso acontece por uma rede WireGuard.
Preciso de um balanceador de carga ou de anycast?
Não. O e-mail de entrada só precisa de vários registros MX com a mesma preferência (RFC 5321 seção 5.1). IMAP, submissão e HTTPS precisam de um nome com TTL curto cujos registros A são mantidos por um job de verificação de integridade. Anycast/BGP é possível, mas complexo.
De quantos sites preciso, no mínimo?
Três votos: dois sites completos mais uma pequena testemunha (etcd, zona Garage, verificador de failover), ou três sites completos. Com apenas dois sites, nenhum dos lados consegue saber com segurança se o outro falhou ou se apenas o link caiu (split brain).
E-mails são perdidos quando um provedor fica offline?
Os remetentes continuam tentando por pelo menos 4 a 5 dias (RFC 5321, seção 4.5.4.1). Com Patroni synchronous_mode e Garage replication_factor 3, uma mensagem confirmada com 250 OK já está armazenada em um segundo site.
15 · Fontes
Todos os fatos externos foram verificados em 2 de novembro de 2026 com base nas seguintes fontes oficiais. Pontos que não puderam ser verificados estão marcados como tal no texto.
- RFC 5321 (SMTP): 4.1.4 EHLO, 4.5.4.1 retry, 5.1 MX: https://www.rfc-editor.org/rfc/rfc5321.html
- RFC 7208 (SPF): 4.6.4 limite de consultas DNS, 5.6 ip4/ip6: https://www.rfc-editor.org/rfc/rfc7208.html
- RFC 6376 (DKIM): 3.1 seletores: https://www.rfc-editor.org/rfc/rfc6376.html
- RFC 8461 (MTA-STS): 3.2, 4.1 padrões de mx: https://www.rfc-editor.org/rfc/rfc8461.html
- RFC 7672 (DANE para SMTP): 3.1 TLSA 3 1 1: https://www.rfc-editor.org/rfc/rfc7672.html
- RFC 8314 (TLS implícito 465/993/995): https://www.rfc-editor.org/rfc/rfc8314.html
- RFC 2177 (IMAP IDLE, reemitir a cada 29 minutos): https://www.rfc-editor.org/rfc/rfc2177.html
- Google: diretrizes para remetentes de e-mail (DNS direto e reverso): https://support.google.com/a/answer/81126
- Hetzner Docs: Floating IPs: https://docs.hetzner.com/cloud/floating-ips/overview
- Hetzner Docs: preços de IPv4: https://docs.hetzner.com/general/others/ipv4-pricing
- Hetzner Docs: IPs de failover do Robot: https://docs.hetzner.com/robot/dedicated-server/ip/failover
- Hetzner Cloud: planos com custo otimizado: https://www.hetzner.com/cloud/cost-optimized/
- Hetzner Docs: FAQ de servidores cloud (portas 25/465): https://docs.hetzner.com/cloud/servers/faq/
- Hetzner Docs: registros PTR: https://docs.hetzner.com/networking/dns/record-types/ptr-record/
- Hetzner Storage Box (preços, protocolos): https://www.hetzner.com/storage/storage-box/
- Hetzner Object Storage (preços): https://www.hetzner.com/storage/object-storage/
- Hetzner Docs: visão geral do Object Storage: https://docs.hetzner.com/storage/object-storage/overview
- Hetzner Docs: Object Lock e retenção: https://docs.hetzner.com/storage/object-storage/howto-protect-objects/protect-object-lock-retention/
- Hetzner Docs: FAQ do Load Balancer: https://docs.hetzner.com/cloud/load-balancers/faq
- Hetzner Docs: FAQ de redes: https://docs.hetzner.com/cloud/networks/faq
- Hetzner Docs: DNS (Console e API): https://docs.hetzner.com/networking/dns/overview/
- netcup: preços de VPS G12.5: https://www.netcup.com/de/server/vps
- netcup: preços do Root Server G12.5: https://www.netcup.com/de/server/root-server
- Central de ajuda da netcup: endereços IP e failover: https://www.netcup.com/en/helpcenter/documentation/server/ip
- netcup: extensões de servidor (preços de IP de failover): https://www.netcup.com/de/server/kvm-server-erweiterungen
- netcup Helpcenter: firewall (política "netcup Mail block"): https://www.netcup.com/en/helpcenter/documentation/server/firewall
- netcup Helpcenter: rede / 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: API de DNS: https://www.netcup.com/en/helpcenter/documentation/domain/our-api
- STRATO: preços VPS Linux: https://www.strato.de/server/linux-vserver/
- STRATO FAQ: DNS reverso para VPS: https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
- STRATO FAQ: firewall de rede do servidor root (porta 25): https://www.strato.de/faq/server/wie-stelle-ich-die-netzwerk-firewall-bei-meinem-windows-root-server-ein/
- Cloudflare: preço do complemento Load Balancing: https://www.cloudflare.com/plans/network-cdn/
- Cloudflare Docs: limitações do Load Balancing (intervalo de health check): https://developers.cloudflare.com/load-balancing/reference/limitations/
- Cloudflare Docs: registros DNS do Load Balancing (MX/SRV somente Enterprise): https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
- Cloudflare Docs: modos de proxy (DNS-only): https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
- Cloudflare Docs: protocolos do Spectrum por plano: https://developers.cloudflare.com/spectrum/protocols-per-plan/
- Cloudflare Docs: TTL de DNS: https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/
- Cloudflare API: editar registro DNS: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
- deSEC: domínios e TTL mínimo: https://desec.readthedocs.io/en/latest/dns/domains.html
- Akamai/Linode: failover de IP (BGP, mesmo data center): https://techdocs.akamai.com/cloud-computing/docs/configure-failover-on-a-compute-instance
- Akamai: preços do NodeBalancer: https://www.akamai.com/cloud/pricing/nodebalancers
- Vultr: requisitos de BGP: https://docs.vultr.com/configuring-bgp-on-vultr
- RIPE NCC: solicitar um número AS: https://www.ripe.net/manage-ips-and-asns/as-numbers/request-an-as-number/
- Proxmox VE: Cluster Manager (latência do corosync, QDevice): https://pve.proxmox.com/wiki/Cluster_Manager
- Proxmox Backup Server: datastores (incl. S3): https://pbs.proxmox.com/docs/storage.html
- WireGuard: https://www.wireguard.com/
- k3s: HA com etcd integrado: https://docs.k3s.io/datastore/ha-embedded
- k3s: cluster híbrido ou multicloud distribuído: https://docs.k3s.io/networking/distributed-multicloud
- etcd: ajuste (heartbeat, election timeout): https://etcd.io/docs/v3.5/tuning/
- Talos Linux: KubeSpan: https://docs.siderolabs.com/talos/v1.11/networking/kubespan
- CloudNativePG: replica clusters: https://cloudnative-pg.io/docs/current/replica_cluster
- Patroni: configuração dinâmica (ttl, loop_wait, retry_timeout): https://patroni.readthedocs.io/en/latest/dynamic_configuration.html
- Patroni: modos de replicação (synchronous_mode, strict): https://patroni.readthedocs.io/en/latest/replication_modes.html
- Patroni: modo failsafe do DCS: https://patroni.readthedocs.io/en/latest/dcs_failsafe_mode.html
- PostgreSQL 18: replicação síncrona: 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 (repositórios 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: página do projeto (vários datacenters, 200 ms / 50 Mbps): https://garagehq.deuxfleurs.fr/
- Garage: configuração (replication_factor, quóruns): https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
- Garage: compatibilidade S3 (sem Object Lock, sem versionamento): https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/
- Npgsql: failover e balanceamento de carga (vários hosts): https://www.npgsql.org/doc/failover-and-load-balancing.html
- Mermaid 12.2.0 (MIT), incluído neste site: https://www.npmjs.com/package/mermaid