Guia de operação Hetzner · netcup · STRATO · situação em 2 de nov. de 2026

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.

CapacidadeComoFonte no código
Backplane do cluster sem RedisPostgreSQL 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 heartbeatsrc/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs
Tarefas do líderpg_try_advisory_lock: exatamente um nó renova os certificados ACME, envia o heartbeat da licença, sincroniza o LDAP e envia lembretes de calendárioAcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs
Fila SMTP entre nósFOR UPDATE SKIP LOCKED: cada nó reivindica itens diferentes da fila, sem entrega duplicadasrc/Nextxch.Storage/Postgres/PostgresMailStore.cs
Bancos de dados com suporte a clusterPostgreSQL (padrão para clusters) e SQL Server (sp_getapplock); SQLite apenas em nó únicosrc/Nextxch.Storage/StorageServiceCollectionExtensions.cs
Armazenamento de blobsFileSystem, S3 (cliente SigV4 próprio com Content-MD5) ou híbrido (cache NVMe local + S3). Modos de Object Lock Normal, Governance, Compliance e legal holdsrc/Nextxch.Storage/Blobs/S3BlobStore.cs, HybridBlobStore.cs
Certificados e DKIMCertificados 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 clienteProtocolo PROXY v1/v2 para SMTP, IMAP, ManageSieve (Cluster:EnableProxyProtocol) e X-Forwarded-For para HTTP a partir de TrustedProxyCidrssrc/Nextxch.Core/Abstractions/IClusterCoordinator.cs
Implantar artefatosdocker-compose.yml, manifestos Kubernetes (3 réplicas, HPA de 2 a 10, PDB, probes), Helm chart, deb/rpm, Windows MSIdeploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/
LicençaSão contadas as caixas de correio ativas, não nós ou pods; o estado da licença é compartilhado via cluster_kvsrc/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).

O único requisito obrigatório
Todos os nós precisam, a todo momento, de exatamente um primário PostgreSQL gravável e de um armazenamento de blobs compartilhado. Sem um banco de dados acessível, /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]

Zona DNS para dois sites (modelo)
; Zone example.com (TTL in seconds). IPs are documentation ranges: replace them.
; Site A = Hetzner 192.0.2.10, site B = netcup 198.51.100.20, site C (medium setup) = 203.0.113.30

; Inbound SMTP: equal preference, senders MUST randomize (RFC 5321 section 5.1).
example.com.            3600 IN MX 10 mx1.example.com.
example.com.            3600 IN MX 10 mx2.example.com.

; Static per-site names: never touched by the failover job (stable FCrDNS for PTR).
mx1.example.com.        3600 IN A     192.0.2.10
mx2.example.com.        3600 IN A     198.51.100.20

; Client name (IMAP, submission, HTTPS, webmail, ActiveSync, autodiscover):
; short TTL, kept up to date by the health-check job. Start with all healthy nodes.
mail.example.com.         60 IN A     192.0.2.10
mail.example.com.         60 IN A     198.51.100.20

; SPF: every outbound IP of every site. ip4/ip6 do not count against the 10-lookup limit (RFC 7208 4.6.4).
example.com.            3600 IN TXT   "v=spf1 ip4:192.0.2.10 ip4:198.51.100.20 ip4:203.0.113.30 -all"

; DKIM: nextxch keeps the keys in the cluster database, so one record set serves all nodes.
; Publish the selectors shown under /admin/ (Domains > DKIM).

; MTA-STS: the policy must list every MX host (see the note on gaps).
_mta-sts.example.com.   3600 IN TXT   "v=STSv1; id=20261102"

; DANE (optional, needs a DNSSEC-signed zone): one TLSA record per MX host.
; nextxch reuses the leaf key on renewal, so "3 1 1" stays stable.
_25._tcp.mx1.example.com. 3600 IN TLSA 3 1 1 <sha256-of-spki>
_25._tcp.mx2.example.com. 3600 IN TLSA 3 1 1 <sha256-of-spki>

; PTR (set in the provider console, not in this zone):
;   192.0.2.10    -> mx1.example.com   (Hetzner Console / Robot)
;   198.51.100.20 -> mx2.example.com   (netcup SCP)
Nome EHLO e PTR
O hostname do servidor (mail.example.com, para clientes, autodiscover e o certificado) vale para todo o cluster. Cada nó define seu próprio nome EHLO/HELO: 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]
Nome EHLO por nó
# 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
Política MTA-STS com todos os hosts MX (servida pelo nextxch por domínio)
# 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çãoEscopoCustoAvaliação
Vários MX com a mesma preferênciaentre provedores0 €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 provedores0 €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 provedoresa partir de US$ 5/mêsHealth 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 Spectrumentre provedoresEnterprisePortas 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 redeIPv4 € 3,00, IPv6 € 1,00/mêsSomente dentro da Hetzner [9]
IP de failover da Hetzner (Robot, dedicado)Data centers da Hetzner€ 4,20/mês + € 4,90 de setupTroca via Robot API em 90 a 110 s, somente dentro da Hetzner [11]
IP de failover da netcupmesmo local da netcup€ 5,00/mês com IVARedirecionamento no SCP a qualquer momento e gratuito, somente dentro da netcup [24][25]
Compartilhamento de IP Akamai/Linodeum data center, dois Linodesa 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óprioglobalaltoASN 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]
Por que uma testemunha é necessária
Com dois sites, nenhum deles consegue saber se o outro falhou ou se apenas o link caiu. Se ambos se promovessem, surgiriam dois bancos de dados divergentes (split brain). O etcd decide por maioria: dois de três membros são suficientes. Por isso: três votos em três provedores, sendo que o terceiro pode ser uma pequena testemunha. [48][53]

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
Arquitetura da configuração S

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]

Não testado e sem Object Lock
O Garage não suporta S3 Object Lock nem versionamento. Domínios com retenção WORM (governance/compliance) fazem o nextxch enviar cabeçalhos de retenção e, portanto, precisam de um S3 com Object Lock; veja o capítulo 09 para a cópia GoBD. A execução do nextxch sobre o Garage não passou por teste de carga para este guia: verifique com dados de teste antes da produção. [62]

Passo a passo

  1. 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]
  2. Configurar PTR: IP A para mx1.example.com (Hetzner Console), IP B para mx2.example.com (netcup SCP). [14][27]
  3. Instalação base via cloud-init (Debian 13): usuário de deploy, Docker, WireGuard, sem login por senha.
  4. Malha WireGuard 10.88.0.0/24 entre A, B e C; abra UDP 51820 apenas para os IPs dos peers. [47]
  5. etcd nos três (election timeout de no mínimo 10 × RTT), depois Patroni + PostgreSQL 18 em A e B. [50][53]
  6. Garage nos três, aplique um layout com três zonas, crie o bucket e a chave. [61]
  7. 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.
  8. 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.
  9. 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-init (user data)
#cloud-config
# Hetzner Cloud / netcup: paste as user data when creating the VM.
users:
  - name: deploy
    groups: [sudo, docker]
    shell: /bin/bash
    sudo: "ALL=(ALL) NOPASSWD:ALL"
    ssh_authorized_keys:
      - ssh-ed25519 AAAA... ci-deploy
package_update: true
packages: [wireguard, curl, jq, docker.io, docker-compose-v2, unattended-upgrades]
runcmd:
  - [ systemctl, enable, --now, docker ]
  - [ sed, -i, "s/^#\\?PasswordAuthentication.*/PasswordAuthentication no/", /etc/ssh/sshd_config ]
  - [ systemctl, restart, ssh ]
WireGuard: /etc/wireguard/wg0.conf
# /etc/wireguard/wg0.conf on site A (Hetzner). Same pattern on B and C.
# Full mesh: every site has one [Peer] block per other site.
[Interface]
Address    = 10.88.0.1/24
ListenPort = 51820
PrivateKey = <private-key-of-site-A>   # wg genkey, never commit it

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

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

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

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

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

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

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

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

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

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

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

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

volumes:
  nextxch-data:

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.

Agente de failover de DNS integrado
# 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.
Por que um nó isolado não altera o DNS
O leader lock só existe por meio de uma conexão com o primário PostgreSQL. Com 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]

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

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

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

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

  have=$(curl -fsS "${AUTH[@]}" "${API}?type=A&name=${NAME}" | jq -r '.result[] | "\(.id) \(.content)"')
  for ip in "${want[@]}"; do   # add missing healthy IPs
    grep -q " ${ip}$" <<<"$have" || curl -fsS "${AUTH[@]}" -X POST "$API" \
      --data "{\"type\":\"A\",\"name\":\"${NAME}\",\"content\":\"${ip}\",\"ttl\":${TTL},\"proxied\":false}" >/dev/null
  done
  while read -r id ip; do      # remove IPs that are no longer healthy
    [ -z "${id:-}" ] && continue
    printf '%s\n' "${want[@]}" | grep -qx "$ip" || curl -fsS "${AUTH[@]}" -X DELETE "${API}/${id}" >/dev/null
  done <<<"$have"
  sleep 20
done
Unidade systemd
# /etc/systemd/system/nextxch-dns-failover.service (install on every site)
[Unit]
Description=nextxch DNS failover (runs only while holding the etcd lock)
After=network-online.target etcd.service wg-quick@wg0.service
Wants=network-online.target

[Service]
EnvironmentFile=/etc/nextxch/dns-failover.env
ExecStart=/usr/bin/etcdctl --endpoints=http://127.0.0.1:2379 lock nextxch-dns /usr/local/bin/nextxch-dns-failover.sh
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

05 · 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"]
Arquitetura da configuração M
Proxmox: sem cluster entre provedores
Segundo a Proxmox, o corosync precisa de latências abaixo de 5 ms (nível de LAN); acima de cerca de 10 ms e com mais de três nós, é improvável que funcione. Portanto, não é recomendado um único cluster Proxmox VE abrangendo Hetzner, netcup e STRATO. O padrão correto: um host ou cluster Proxmox independente por provedor, com a alta disponibilidade fornecida pela aplicação (Patroni, Garage, cluster nextxch). Um QDevice (corosync-qnetd) é destinado a clusters de dois nós em um único site e não está sujeito ao limite de latência. O Proxmox Datacenter Manager (1.0 desde 04/12/2025) gerencia vários clusters em conjunto. [45]

Passo a passo

  1. Três VMs (ou guests Proxmox) em três provedores, mesma versão do Debian, PTR para mx1/mx2/mx3 em cada uma.
  2. Inventário Ansible com três hosts, um playbook para todos (WireGuard, etcd, Patroni, Garage, nextxch).
  3. 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]
  4. 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.
  5. 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]
Ansible: inventory/ha.yml
# inventory/ha.yml: three sites, one VM (or Proxmox guest) per site
all:
  vars:
    ansible_user: deploy
    wg_network: 10.88.0.0/24
    pg_major: 18
  children:
    nextxch_sites:
      hosts:
        hetzner-a: { ansible_host: 192.0.2.10,    wg_ip: 10.88.0.1, garage_zone: hetzner }
        netcup-b:  { ansible_host: 198.51.100.20, wg_ip: 10.88.0.2, garage_zone: netcup }
        third-c:   { ansible_host: 203.0.113.30,  wg_ip: 10.88.0.3, garage_zone: third }
Ansible: site.yml
# site.yml: idempotent base for every site (same playbook for Hetzner, netcup, Strato ...)
- hosts: nextxch_sites
  become: true
  tasks:
    - name: Base packages (Debian 13)
      ansible.builtin.apt:
        name: [wireguard, etcd-server, etcd-client, patroni, pgbackrest, docker.io, docker-compose-v2, jq, curl]
        update_cache: true
    # PostgreSQL 18 itself comes from the PGDG apt repository (apt.postgresql.org),
    # Garage as the static binary or the dxflrs/garage image.

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

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

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

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

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

06 · Setup L: Kubernetes 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
Configuração de arquitetura L

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

  1. Construa a camada de dados como na configuração M (três VMs com Patroni, etcd, Garage).
  2. Um cluster k3s ou Talos por provedor; conexão de rede com a camada de dados via WireGuard (ou KubeSpan).
  3. 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, use Cluster__EnableProxyProtocol=true. O Hetzner Cloud Load Balancer suporta o protocolo PROXY e qualquer serviço TCP. [19]
  4. 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).
  5. 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.
Helm: k8s/values-hetzner.yaml
# values-hetzner.yaml (one file per provider cluster; netcup/strato analogue)
# Helm chart: deploy/helm/nextxch in the nextxch repository.
replicaCount: 3
mail:
  hostname: mail.example.com
  defaultDomain: example.com
postgres:
  # Patroni data layer outside Kubernetes, reached through WireGuard / KubeSpan
  connectionString: "Host=10.88.0.1,10.88.0.2,10.88.0.3;Database=nextxch;Username=nextxch;Password=<from secret>;Target Session Attributes=primary"
s3:
  endpointUrl: "http://garage.nextxch-data.svc:3900"   # Garage zone of this provider
  bucketName: "nextxch"

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

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

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

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

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

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

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

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

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

jobs:
  helm:
    runs-on: ubuntu-latest
    strategy:
      max-parallel: 1
      matrix:
        cluster: [hetzner, netcup, strato]
    environment: k8s-${{ matrix.cluster }}
    steps:
      - uses: actions/checkout@v4
      - uses: azure/setup-helm@v4
      - name: Kubeconfig
        run: |
          mkdir -p ~/.kube
          echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
          chmod 600 ~/.kube/config
      - name: Helm upgrade (waits for readiness probes on /health/ready)
        run: |
          helm upgrade --install nextxch ./deploy/helm/nextxch \
            --namespace nextxch --create-namespace \
            -f k8s/values-common.yaml -f k8s/values-${{ matrix.cluster }}.yaml \
            --set image.tag=${{ vars.NEXTXCH_TAG }} \
            --wait --timeout 10m --atomic
O job de failover não pertence ao CI
Execuções agendadas de CI não são precisas ao segundo nem garantidas. Por isso, o failover de DNS roda nos próprios sites: como agente integrado no nextxch ou como serviço systemd com o script (capítulo 04), que o pipeline apenas distribui.
A configuração de e-mail também a partir do Git
Domínios, caixas de correio, listas, regras, SMTP e as demais configurações também ficam no repositório como YAML: 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
Sequência quando o site A falha
TempoO que aconteceO que os usuários percebem
0 sA 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 sA 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 sO 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 sO 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 minResolvedores 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
Retorno do site 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
Fluxo de backup
  • 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.
pgBackRest: /etc/pgbackrest/pgbackrest.conf
# /etc/pgbackrest/pgbackrest.conf on every PostgreSQL site.
# Repo 1: Hetzner Storage Box over SFTP (pgBackRest 2.46+), repo 2: S3 at another provider.
[global]
repo1-type=sftp
repo1-path=/pgbackrest
repo1-sftp-host=uXXXXXX.your-storagebox.de
repo1-sftp-host-port=23
repo1-sftp-host-user=uXXXXXX
repo1-sftp-private-key-file=/var/lib/postgresql/.ssh/id_ed25519
repo1-sftp-host-key-hash-type=sha256
repo1-retention-full=2
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<long random secret, stored outside the repo>

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

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

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

# Weekly full, daily differential (cron on every node; pgBackRest runs only where it can,
# use --repo and the "backup-standby" option to take backups from a replica).
#   0 2 * * 0  pgbackrest --stanza=nextxch --type=full backup
#   0 2 * * 1-6 pgbackrest --stanza=nextxch --type=diff backup
rclone: cópia de blobs e bucket GoBD
# Blob copy (all .eml objects) from Garage to an independent target, every hour.
# ~/.config/rclone/rclone.conf (keys are placeholders)
[garage]
type = s3
provider = Other
endpoint = http://127.0.0.1:3900
region = garage
access_key_id = <garage key>
secret_access_key = <garage secret>

[archive]
type = s3
provider = Other
endpoint = <object storage endpoint>
access_key_id = <key>
secret_access_key = <secret>

# Content-addressed (SHA-256 names, never modified): "copy" is enough and never deletes.
#   rclone copy garage:nextxch archive:nextxch-archive --immutable --transfers 8 --checksum
# For a GoBD copy create the target bucket WITH Object Lock and a default retention
# (COMPLIANCE, e.g. 10 years) so every copied object is locked automatically.

10 · 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"]
Caminho de escalabilidade
ComponenteEscalaObservação
Nós / pods do nextxchhorizontalmenteStateless; mais nós por site ou mais pods (HPA). Não são necessárias sticky sessions.
Primário PostgreSQLverticalmenteTodas 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 blobshorizontalmenteMais 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.
SMTPvia DNSMais 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.
IMAPvia DNS / LBConexõ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 / LBSessõ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]

ProvedorPlanovCPURAMDisco€ líquido / mês
Hetzner CloudCX2324 GB40 GB€6.49
Hetzner CloudCX3348 GB80 GB€9.49
Hetzner CloudCX43816 GB160 GB€16.99
Hetzner CloudCX531632 GB320 GB€30.49
netcup VPSVPS 500 G12.524 GB64 GB€6.94
netcup VPSVPS 1000 G12.548 GB128 GB€12.18
netcup VPSVPS 2000 G12.5816 GB256 GB€22.62
netcup VPSVPS 4000 G12.51232 GB512 GB€38.12
netcup VPSVPS 8000 G12.51664 GB1024 GB€56.39
netcup Root ServerRS 1000 G12.548 GB128 GB€18.26
netcup Root ServerRS 2000 G12.5816 GB256 GB€34.20
netcup Root ServerRS 4000 G12.51232 GB512 GB€65.40
netcup Root ServerRS 8000 G12.51664 GB1024 GB€124.72
netcup Root ServerRS 12000 G12.52096 GB1536 GB€182.26
netcup Root ServerRS 16000 G12.524128 GB2048 GB€243.24
STRATO VPSVPS S12 GB60 GB€3.36
STRATO VPSVPS M24 GB120 GB€8.40
STRATO VPSVPS L48 GB240 GB€16.81
STRATO VPSVPS XL816 GB480 GB€32.77
STRATO VPSVPS XXL1224 GB720 GB€47.06
Hetzner Storage BoxBX11--1 TB€3.20
Hetzner Storage BoxBX21--5 TB€10.90
Hetzner Storage BoxBX31--10 TB€20.80
Hetzner Storage BoxBX41--20 TB€40.60
Hetzner Object Storagepreç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)

  1. 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]
  2. 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.
  3. etcd: adicione o membro com etcdctl member add e inicie-o com ETCD_INITIAL_CLUSTER_STATE=existing. Com quatro sites de banco de dados, mantenha ímpar o número de membros etcd. [48]
  4. O Patroni inicia como réplica e replica automaticamente. Garage: garage layout assign com a nova zona, garage layout apply.
  5. 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.
  6. 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.

LacunaImpactoSoluçã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 S3Sem 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 integradoNão há serviço de backup para banco de dados e blobs no servidor.pgBackRest e rclone (capítulo 09).
Sem réplicas de leituraTodas 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 medidoOs 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.

  1. RFC 5321 (SMTP): 4.1.4 EHLO, 4.5.4.1 retry, 5.1 MX: https://www.rfc-editor.org/rfc/rfc5321.html
  2. RFC 7208 (SPF): 4.6.4 limite de consultas DNS, 5.6 ip4/ip6: https://www.rfc-editor.org/rfc/rfc7208.html
  3. RFC 6376 (DKIM): 3.1 seletores: https://www.rfc-editor.org/rfc/rfc6376.html
  4. RFC 8461 (MTA-STS): 3.2, 4.1 padrões de mx: https://www.rfc-editor.org/rfc/rfc8461.html
  5. RFC 7672 (DANE para SMTP): 3.1 TLSA 3 1 1: https://www.rfc-editor.org/rfc/rfc7672.html
  6. RFC 8314 (TLS implícito 465/993/995): https://www.rfc-editor.org/rfc/rfc8314.html
  7. RFC 2177 (IMAP IDLE, reemitir a cada 29 minutos): https://www.rfc-editor.org/rfc/rfc2177.html
  8. Google: diretrizes para remetentes de e-mail (DNS direto e reverso): https://support.google.com/a/answer/81126
  9. Hetzner Docs: Floating IPs: https://docs.hetzner.com/cloud/floating-ips/overview
  10. Hetzner Docs: preços de IPv4: https://docs.hetzner.com/general/others/ipv4-pricing
  11. Hetzner Docs: IPs de failover do Robot: https://docs.hetzner.com/robot/dedicated-server/ip/failover
  12. Hetzner Cloud: planos com custo otimizado: https://www.hetzner.com/cloud/cost-optimized/
  13. Hetzner Docs: FAQ de servidores cloud (portas 25/465): https://docs.hetzner.com/cloud/servers/faq/
  14. Hetzner Docs: registros PTR: https://docs.hetzner.com/networking/dns/record-types/ptr-record/
  15. Hetzner Storage Box (preços, protocolos): https://www.hetzner.com/storage/storage-box/
  16. Hetzner Object Storage (preços): https://www.hetzner.com/storage/object-storage/
  17. Hetzner Docs: visão geral do Object Storage: https://docs.hetzner.com/storage/object-storage/overview
  18. Hetzner Docs: Object Lock e retenção: https://docs.hetzner.com/storage/object-storage/howto-protect-objects/protect-object-lock-retention/
  19. Hetzner Docs: FAQ do Load Balancer: https://docs.hetzner.com/cloud/load-balancers/faq
  20. Hetzner Docs: FAQ de redes: https://docs.hetzner.com/cloud/networks/faq
  21. Hetzner Docs: DNS (Console e API): https://docs.hetzner.com/networking/dns/overview/
  22. netcup: preços de VPS G12.5: https://www.netcup.com/de/server/vps
  23. netcup: preços do Root Server G12.5: https://www.netcup.com/de/server/root-server
  24. Central de ajuda da netcup: endereços IP e failover: https://www.netcup.com/en/helpcenter/documentation/server/ip
  25. netcup: extensões de servidor (preços de IP de failover): https://www.netcup.com/de/server/kvm-server-erweiterungen
  26. netcup Helpcenter: firewall (política "netcup Mail block"): https://www.netcup.com/en/helpcenter/documentation/server/firewall
  27. netcup Helpcenter: rede / rDNS: https://www.netcup.com/en/helpcenter/documentation/server/network-server
  28. netcup: Storage Space (NFS): https://www.netcup.com/en/server/storage-space
  29. netcup Helpcenter: API de DNS: https://www.netcup.com/en/helpcenter/documentation/domain/our-api
  30. STRATO: preços VPS Linux: https://www.strato.de/server/linux-vserver/
  31. STRATO FAQ: DNS reverso para VPS: https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
  32. 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/
  33. Cloudflare: preço do complemento Load Balancing: https://www.cloudflare.com/plans/network-cdn/
  34. Cloudflare Docs: limitações do Load Balancing (intervalo de health check): https://developers.cloudflare.com/load-balancing/reference/limitations/
  35. Cloudflare Docs: registros DNS do Load Balancing (MX/SRV somente Enterprise): https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
  36. Cloudflare Docs: modos de proxy (DNS-only): https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
  37. Cloudflare Docs: protocolos do Spectrum por plano: https://developers.cloudflare.com/spectrum/protocols-per-plan/
  38. Cloudflare Docs: TTL de DNS: https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/
  39. Cloudflare API: editar registro DNS: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
  40. deSEC: domínios e TTL mínimo: https://desec.readthedocs.io/en/latest/dns/domains.html
  41. Akamai/Linode: failover de IP (BGP, mesmo data center): https://techdocs.akamai.com/cloud-computing/docs/configure-failover-on-a-compute-instance
  42. Akamai: preços do NodeBalancer: https://www.akamai.com/cloud/pricing/nodebalancers
  43. Vultr: requisitos de BGP: https://docs.vultr.com/configuring-bgp-on-vultr
  44. RIPE NCC: solicitar um número AS: https://www.ripe.net/manage-ips-and-asns/as-numbers/request-an-as-number/
  45. Proxmox VE: Cluster Manager (latência do corosync, QDevice): https://pve.proxmox.com/wiki/Cluster_Manager
  46. Proxmox Backup Server: datastores (incl. S3): https://pbs.proxmox.com/docs/storage.html
  47. WireGuard: https://www.wireguard.com/
  48. k3s: HA com etcd integrado: https://docs.k3s.io/datastore/ha-embedded
  49. k3s: cluster híbrido ou multicloud distribuído: https://docs.k3s.io/networking/distributed-multicloud
  50. etcd: ajuste (heartbeat, election timeout): https://etcd.io/docs/v3.5/tuning/
  51. Talos Linux: KubeSpan: https://docs.siderolabs.com/talos/v1.11/networking/kubespan
  52. CloudNativePG: replica clusters: https://cloudnative-pg.io/docs/current/replica_cluster
  53. Patroni: configuração dinâmica (ttl, loop_wait, retry_timeout): https://patroni.readthedocs.io/en/latest/dynamic_configuration.html
  54. Patroni: modos de replicação (synchronous_mode, strict): https://patroni.readthedocs.io/en/latest/replication_modes.html
  55. Patroni: modo failsafe do DCS: https://patroni.readthedocs.io/en/latest/dcs_failsafe_mode.html
  56. PostgreSQL 18: replicação síncrona: https://www.postgresql.org/docs/current/warm-standby.html#SYNCHRONOUS-REPLICATION
  57. PostgreSQL 18: shared_buffers: https://www.postgresql.org/docs/current/runtime-config-resource.html
  58. pgBackRest (repositórios S3 e SFTP): https://pgbackrest.org/
  59. CISA: Data Backup Options (3-2-1): https://www.cisa.gov/sites/default/files/publications/data_backup_options.pdf
  60. Garage: página do projeto (vários datacenters, 200 ms / 50 Mbps): https://garagehq.deuxfleurs.fr/
  61. Garage: configuração (replication_factor, quóruns): https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
  62. Garage: compatibilidade S3 (sem Object Lock, sem versionamento): https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/
  63. Npgsql: failover e balanceamento de carga (vários hosts): https://www.npgsql.org/doc/failover-and-load-balancing.html
  64. Mermaid 12.2.0 (MIT), incluído neste site: https://www.npmjs.com/package/mermaid