nextxch de alta disponibilidad en varios proveedores
Cuando un proveedor completo queda fuera de línea, el correo debe seguir fluyendo: sin anycast costoso, sin balanceadores de carga empresariales, desplegable desde Git. Esta guía muestra tres configuraciones en servidores económicos de Hetzner, netcup y STRATO, explica segundo a segundo lo que ocurre durante una caída y calcula recursos y costes. Cada afirmación sobre un proveedor enlaza a una fuente con la fecha de comprobación; cada afirmación sobre nextxch indica el archivo fuente en el código.
01 · Lo que nextxch ofrece para la alta disponibilidad (verificado en el código)
nextxch admite active-active: cada nodo acepta SMTP, IMAP, JMAP, ActiveSync y HTTPS; no hay nodos pasivos ni sesiones persistentes. PostgreSQL contiene el estado compartido; los propios mensajes se almacenan como objetos .eml direccionados por SHA-256 en el almacén de blobs.
| Capacidad | Cómo | Fuente en el código |
|---|---|---|
| Backplane del clúster sin Redis | PostgreSQL LISTEN/NOTIFY (canal nextxch_cluster_events) para eventos como correo nuevo, marcas, expunge, cola; tablas UNLOGGED cluster_ephemeral y cluster_nodes; heartbeat cada 15 s, los nodos se descartan tras 60 s sin heartbeat | src/Nextxch.Storage/Cluster/PostgresClusterCoordinator.cs |
| Tareas del líder | pg_try_advisory_lock: exactamente un nodo renueva los certificados ACME, envía el heartbeat de la licencia, sincroniza LDAP y envía los recordatorios de calendario | AcmeCertificateManager.cs, ClusterLicenseService.cs, LdapSyncService.cs, CalendarReminderPushService.cs |
| Cola SMTP entre nodos | FOR UPDATE SKIP LOCKED: cada nodo reclama elementos distintos de la cola, sin entregas duplicadas | src/Nextxch.Storage/Postgres/PostgresMailStore.cs |
| Bases de datos con capacidad de clúster | PostgreSQL (predeterminado para clústeres) y SQL Server (sp_getapplock); SQLite solo en un único nodo | src/Nextxch.Storage/StorageServiceCollectionExtensions.cs |
| Almacén de blobs | FileSystem, S3 (cliente SigV4 propio con Content-MD5) o híbrido (caché NVMe local + S3). Modos de Object Lock Normal, Governance, Compliance y retención legal | src/Nextxch.Storage/Blobs/S3BlobStore.cs, HybridBlobStore.cs |
| Certificados y DKIM | Los certificados ACME y las claves DKIM residen en la base de datos; los desafíos HTTP-01 se comparten en todo el clúster, de modo que cualquier nodo puede responder; la clave del certificado final se conserva en la renovación (DANE 3 1 1 sigue siendo válido) | src/Nextxch.Security/Tls/AcmeCertificateManager.cs |
| Endpoints de estado | /health/live (sin BD), /health/ready (BD accesible; si no, 503), /health (vista general incl. pares del clúster) | src/Nextxch.Server/Program.cs |
| IP real del cliente | Protocolo PROXY v1/v2 para SMTP, IMAP, ManageSieve (Cluster:EnableProxyProtocol) y X-Forwarded-For para HTTP desde TrustedProxyCidrs | src/Nextxch.Core/Abstractions/IClusterCoordinator.cs |
| Desplegar artefactos | docker-compose.yml, manifiestos de Kubernetes (3 réplicas, HPA de 2 a 10, PDB, sondas), Helm chart, deb/rpm, Windows MSI | deploy/kubernetes/, deploy/helm/nextxch/, deploy/packages/ |
| Licencia | Se cuentan los buzones activos, no los nodos ni los pods; el estado de la licencia se comparte a través de cluster_kv | src/Nextxch.Server/Services/ClusterLicenseService.cs, Precios |
Puertos por nodo: 25, 465, 587 (SMTP), 143, 993 (IMAP), 4190 (ManageSieve), 80/443 (HTTPS, ACME, webmail, ActiveSync, JMAP). POP3 110/995 está desactivado de forma predeterminada (Pop3:Enabled=false en Pop3Settings.cs).
/health/ready devuelve 503 y el nodo no puede verificar destinatarios. Por tanto, alta disponibilidad entre proveedores significa replicar PostgreSQL y el almacén de blobs entre sitios y conmutar automáticamente. Eso es lo que construyen las tres configuraciones.02 · Reglas propias del correo que facilitan la alta disponibilidad
Entrante: MX incluye failover de serie
Los remitentes ordenan los registros MX por preferencia y deben probar todas las direcciones; con la misma preferencia deben elegir al azar. [1] Si un destino no es accesible, el intervalo de reintento debería ser de al menos 30 minutos y el tiempo de abandono de al menos 4 a 5 días. [1]
Saliente: SPF y PTR por IP
SPF debe incluir todas las IP salientes de todos los sitios; ip4/ip6 no cuentan para el límite de 10 consultas DNS. [2] Gmail exige un DNS directo e inverso válido; el PTR se configura por separado en cada proveedor. [8][14][27][31]
DKIM, MTA-STS, DANE
Las claves DKIM residen en la base de datos del clúster; un único conjunto DNS sirve a todos los nodos; los selectores permiten varias claves en paralelo. [3] La política MTA-STS debe nombrar cada host MX; los comodines solo cubren la etiqueta situada más a la izquierda. [4] DANE necesita un registro TLSA por host MX (se recomienda 3 1 1) y una zona firmada con DNSSEC. [5]
Clientes: IMAP, envío, HTTPS
Los clientes de correo no tienen un failover al estilo de MX. Se conectan a un nombre (aquí mail.example.com) cuyos registros A mantiene un trabajo de comprobación de estado. Las conexiones IDLE se renuevan al menos cada 29 minutos y entonces llegan a un nodo en buen estado. [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 en la configuración o en /admin/ en "Hostname, IPv6 & Ports" (nombre EHLO por nodo, con comprobación de PTR por nodo). Se utiliza para el banner 220, la respuesta EHLO y el EHLO saliente. Haga que el PTR de cada IP apunte a su nombre fijo mx1/mx2; el asesor DNS muestra un registro SPF HELO por nodo. De todos modos, la RFC 5321, sección 4.1.4, prohíbe rechazar un mensaje solo porque el nombre EHLO no coincida. [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 · Cómo se redirige a los clientes: comparación de opciones
Las IP de failover de los proveedores solo ayudan dentro de un proveedor. Para la caída de un proveedor completo, el DNS sigue siendo la herramienta sencilla y económica.
| Opción | Alcance | Coste | Valoración |
|---|---|---|---|
| Varios MX con la misma preferencia | entre proveedores | 0 € | Obligatorio para el correo entrante, estandarizado [1] |
| Job de comprobación de estado propio + API DNS (Cloudflare, Hetzner, deSEC, netcup) | entre proveedores | 0 € | Recomendado. Cloudflare: TTL desde 60 s (Enterprise 30 s), auto = 300 s. Hetzner DNS: gratuito, TTL desde 60 s, API en la Hetzner Console. deSEC: TTL mínimo por dominio, por ejemplo 3600 s, más bajo solo bajo petición. netcup: existe la API DNS del CCP, TTL mínimo no documentado. [38][39][21][40][29] |
| Cloudflare Load Balancing (solo DNS) | entre proveedores | desde 5 USD/mes | Comprobaciones de estado (incl. TCP, SMTP) cada 15 s fuera de Enterprise; para A/AAAA/CNAME, MX/SRV solo en Enterprise. Sustituye al job propio para mail.example.com. [33][34][35][36] |
| Cloudflare Spectrum | entre proveedores | Enterprise | Puertos TCP arbitrarios (25, 465, 587, 993) solo como complemento Enterprise; Pro/Business solo SSH, Minecraft, RDP. [37] |
| Hetzner Floating IP (Cloud) | un proyecto, una zona de red | IPv4 3,00 €, IPv6 1,00 €/mes | Solo dentro de Hetzner [9] |
| IP de failover de Hetzner (Robot, dedicado) | Centros de datos de Hetzner | 4,20 €/mes + 4,90 € de configuración | Conmutación mediante la API de Robot en 90 a 110 s, solo dentro de Hetzner [11] |
| IP de failover de netcup | misma ubicación de netcup | 5,00 €/mes IVA incluido | Redirección en el SCP en cualquier momento y sin coste, solo dentro de netcup [24][25] |
| IP sharing de Akamai/Linode | un centro de datos, dos Linodes | desde 5 USD (Nanode) | Basado en BGP (lelastic), solo en el mismo centro de datos; NodeBalancer Basic 10 USD, TCP/HTTP [41][42] |
| Anycast / BGP propio | global | alta | ASN propio (contrato RIPE mediante membresía o un LIR patrocinador) más espacio de direcciones propio (en Vultr, al menos /24 IPv4 o /48 IPv6). Desproporcionado para la mayoría de las instalaciones de nextxch. [43][44] |
04 · Configuración S: dos sitios más testigo
Para pequeñas y medianas empresas. El sitio A (p. ej., Hetzner Cloud) y el sitio B (p. ej., netcup VPS) ejecutan cada uno nextxch, PostgreSQL bajo Patroni, un miembro de etcd y una zona de Garage. Un pequeño testigo C en un tercer proveedor (p. ej., STRATO) aloja el tercer miembro de etcd, la tercera copia de Garage y el comprobador de failover de DNS. Los tres están interconectados en malla con 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 qué Garage como almacén de blobs?
Un bucket S3 en un único proveedor desaparecería con ese proveedor, y actualmente nextxch solo puede escribir en un destino S3. Garage es un almacén compatible con S3 (AGPL-3.0) diseñado para varios centros de datos a través de Internet (requisito del proyecto: como máximo 200 ms de latencia, al menos 50 Mbit/s). Con replication_factor = 3 hay una copia por zona; las lecturas y escrituras continúan mientras solo falle una zona (quórum de escritura 2). Con solo dos zonas, la pérdida de una dejaría el almacén en solo lectura; por eso el testigo también guarda una copia completa. [60][61]
Paso a paso
- Pedir servidores: A = Hetzner Cloud (los puertos 25/465 están bloqueados de forma predeterminada; solicite el desbloqueo tras un mes y una primera factura pagada; el 587 está abierto), B = netcup VPS (elimine la política de firewall "netcup Mail block" en el SCP), C = STRATO VPS como testigo (el testigo no necesita el puerto 25). [13][26]
- Configurar PTR: IP A a mx1.example.com (Hetzner Console), IP B a mx2.example.com (netcup SCP). [14][27]
- Instalación base mediante cloud-init (Debian 13): usuario de despliegue, Docker, WireGuard, sin inicio de sesión con contraseña.
- Malla WireGuard 10.88.0.0/24 entre A, B y C; abra UDP 51820 solo para las IP de los pares. [47]
- etcd en los tres (tiempo de espera de elección de al menos 10 × RTT), después Patroni + PostgreSQL 18 en A y B. [50][53]
- Garage en los tres, aplique un layout con tres zonas, cree el bucket y la clave. [61]
- nextxch mediante Docker Compose en A y B (Cluster:Enabled=true, cadena de conexión multihost, S3 apuntando al Garage local). En /admin/, configure el nombre de host mail.example.com y emita el certificado ACME para mail, mx1 y mx2.
- DNS como en el capítulo 02: compruebe la lista MX de MTA-STS en /admin/, active el failover DNS en /admin/ en Dominios (solo actúa el líder del clúster) o, como alternativa, el script bajo un bloqueo de etcd.
- Copias de seguridad como en el capítulo 09 y pruebe una restauración. Después, ensaye una caída: detenga en seco el sitio A y siga el 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:
La cadena de conexión nombra ambos hosts PostgreSQL con Target Session Attributes=primary. nextxch crea sus conexiones mediante NpgsqlDataSource.Create (PostgresMailStore.cs); con varios hosts, Npgsql crea automáticamente un origen de datos multihost y se conecta solo al primario actual, comprobando de nuevo el estado cada Host Recheck Seconds (predeterminado 10 s). Tras un failover de Patroni, los nodos se reconectan sin ningún cambio de configuración; la conexión LISTEN del clúster lo reintenta cada 2 s (PostgresClusterCoordinator.cs). [63]
Failover de DNS automático para mail.example.com
nextxch incluye un agente de failover de DNS (opcional, desactivado de forma predeterminada; /admin/ en Dominios, tarjeta "DNS failover"). Cada nodo comprueba cada 30 s el banner SMTP en el puerto 25 y HTTPS /health de cada nodo a través de su IP pública. Solo actúa el líder del clúster: mantiene un advisory lock de PostgreSQL, combina por mayoría la visión de todos los nodos y escribe los registros A/AAAA de los nombres configurados a través de la API de Hetzner DNS (Cloud API) o de Cloudflare. Histéresis: tras N rondas fallidas, un nodo sale del DNS; tras M rondas correctas, vuelve; el último nodo nunca se elimina. Cada cambio queda en el registro de eventos y la regla de alerta "DNS failover: node taken out" notifica a través de las alertas de operación. El token de API se cifra con el keyring del servidor y no vuelve a mostrarse nunca.
# 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 en la cadena de conexión (capítulo 04, arriba), un lado aislado no alcanza ningún primario, no obtiene el bloqueo y permanece pasivo. La votación por mayoría entre todos los observadores impide que un único nodo con una visión errónea elimine un nodo sano.Alternativa: script bajo un bloqueo de etcd
Para proveedores DNS sin conector integrado (p. ej., deSEC), o cuando etcd deba tomar la decisión, el script sigue siendo una alternativa equivalente. No ejecute el agente y el script para los mismos nombres al mismo tiempo. El script comprueba /health/ready de cada nodo a través de su IP pública cada 20 s y concilia los registros A mediante la API de Cloudflare (PATCH/POST/DELETE en dns_records). Se ejecuta en cada sitio bajo etcdctl lock: solo actúa una copia, y solo en el lado con mayoría de etcd. Por tanto, un sitio aislado ya no puede modificar el DNS. Para Hetzner DNS o deSEC solo cambian las tres llamadas 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 · Configuración M: tres sitios completos (Proxmox o VM simples)
Como la configuración S, pero los tres sitios ejecutan nextxch con una réplica de PostgreSQL. Si falla un sitio, quedan dos bases de datos y dos nodos nextxch, y con synchronous_mode se mantiene incluso la replicación síncrona. Tres registros MX con la misma preferencia reparten la carga entrante.
flowchart TB
S["Remote mail servers"] -->|"MX 10 mx1, mx2, mx3"| N
U["Users"] -->|"mail.example.com"| N
subgraph N["WireGuard mesh 10.88.0.0/24"]
direction LR
subgraph A["Hetzner"]
A1["nextxch"] --- A2[("Patroni + PG")] --- A3["etcd + Garage"]
end
subgraph B["netcup"]
B1["nextxch"] --- B2[("Patroni + PG")] --- B3["etcd + Garage"]
end
subgraph C["Third provider"]
C1["nextxch"] --- C2[("Patroni + PG")] --- C3["etcd + Garage"]
end
end
N --> BK["Backups: Storage Box + S3 at another provider"]
Paso a paso
- Tres VM (o invitados de Proxmox) en tres proveedores, con la misma versión de Debian y PTR a mx1/mx2/mx3 respectivamente.
- Inventario de Ansible con tres hosts, un playbook para todo (WireGuard, etcd, Patroni, Garage, nextxch).
- Configuración de Patroni de la configuración S con un tercer host; synchronous_mode: on, con tres bases de datos opcionalmente por quórum. [54]
- Cadena de conexión con tres hosts, diseño de Garage con tres zonas, tres registros MX, tres IP en SPF y en la lista de nodos del failover de DNS.
- Proxmox Backup Server opcional para imágenes de VM: datastore local o S3 (backend S3 compatible oficialmente desde PBS 4.2); Proxmox no documenta recursos compartidos de red como una Storage Box mediante 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 · Configuración L: Kubernetes en tres proveedores
Para muchos buzones y escalado horizontal. Primero, algo importante: k3s explícitamente no admite etcd integrado en un clúster distribuido entre varias nubes; los nodos servidor deben permanecer en una sola ubicación. Por tanto: un clúster de Kubernetes por proveedor (k3s o Talos en cada caso), cada uno con un despliegue de nextxch a partir del Helm chart incluido. La capa de datos (Patroni + etcd + Garage) se ejecuta en sus propias VM a través de WireGuard, como en la configuración M, y la utilizan los tres clústeres. [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 qué no CloudNativePG entre clústeres?
CloudNativePG puede ejecutar clústeres de réplica en otros clústeres de Kubernetes, pero la promoción y la degradación entre clústeres son declarativas (campo primary, promotionToken), es decir, no automáticas. Para un failover automático entre proveedores, Patroni con etcd en tres sitios es la solución más directa. [52]
¿Un clúster en todos los proveedores?
Talos ofrece KubeSpan, una malla completa WireGuard entre sitios, y k3s un backend flannel wireguard-native. Sin embargo, el plano de control (etcd) es sensible a la latencia WAN. Varios clústeres pequeños son más sencillos de operar y fallan de forma independiente. [51][49][50]
Paso a paso
- Construya la capa de datos como en la configuración M (tres VM con Patroni, etcd, Garage).
- Un clúster k3s o Talos por proveedor; conexión de red con la capa de datos mediante WireGuard (o KubeSpan).
- Puertos de entrada: servicio de tipo LoadBalancer con
externalTrafficPolicy: Local(como en el repositorio) para conservar la IP del remitente para SPF/DMARC; con un balanceador de carga que hable el protocolo PROXY, use en su lugarCluster__EnableProxyProtocol=true. El Hetzner Cloud Load Balancer admite el protocolo PROXY y cualquier servicio TCP. [19] - Puerto 25 saliente: salida de los pods a través de una IP fija con PTR y entrada SPF coincidentes (egress gateway, como se describe en deploy/kubernetes/README.md).
- Valores de Helm por proveedor, despliegue mediante pipeline uno tras otro (capítulo 07). HPA (de 2 a 10 pods al 70 % de CPU) y PodDisruptionBudget se incluyen en el repositorio.
# 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 · Desplegar y actualizar desde Git
Todo lo importante es un archivo en el repositorio: archivo compose o valores de Helm, inventario de Ansible, script de failover de DNS. El pipeline despliega siempre en un solo sitio y espera hasta que /health/ready vuelva a indicar “ready” antes de continuar. Los secretos (licencia, contraseñas, tokens de API, claves SSH) solo se guardan en los almacenes de secretos de GitHub/GitLab o en el .env del servidor, nunca en el repositorio.
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 el pull request con el plan, config apply --prune --yes lo aplica tras el merge. Guía con archivos listos para GitHub Actions y GitLab CI: Configuración como código (GitOps).08 · Escenarios de fallo: qué ocurre exactamente
Hetzner (sitio A, primario actual) se desconecta por completo
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
| Tiempo | Qué ocurre | Lo que notan los usuarios |
|---|---|---|
| 0 s | A ha caído. El correo entrante a mx1 falla; los remitentes usan mx2 (misma preferencia) o lo reintentan más tarde. [1] | Los clientes en A pierden su conexión. |
| hasta unos 30 s | La clave de líder de A en etcd caduca tras ttl = 30 s; B y C tienen la mayoría. [53] | El nodo B devuelve 503 en /health/ready; las escrituras esperan. |
| unos 30 a 60 s | Patroni promueve B (loop_wait 10 s). nextxch en B encuentra el nuevo primario a través de la cadena de conexión multihost (nueva comprobación en 5 a 10 s). [63] | B vuelve a aceptar correo. |
| unos 60 a 90 s | El failover de DNS (agente: 3 rondas fallidas × 30 s; script: 3 × 20 s) elimina la IP A de mail.example.com. | Las nuevas conexiones van a B una vez que ha expirado el TTL (60 s). |
| 1 a 5 min | Los resolvers con sus propios TTL mínimos y los clientes con caché DNS siguen después. Garage continúa con las zonas B y C (quórum 2 de 3). [61] | Los clientes se reconectan; las sesiones de webmail siguen siendo válidas (sesiones firmadas y sin estado). |
RPO
Con synchronous_mode, PostgreSQL confirma un commit solo cuando el segundo sitio lo tiene; un mensaje confirmado con 250 OK existe entonces en dos sitios. El precio: cada commit espera al menos un viaje de ida y vuelta entre los sitios. Sin synchronous_mode pueden faltar los últimos segundos antes de la caída. Con synchronous_mode_strict: false, Patroni vuelve al modo asíncrono cuando no queda ninguna réplica, de modo que se sigue aceptando correo. [56][54]
RTO
Base de datos unos 30 a 60 s (valores predeterminados de Patroni), cambio de DNS aproximadamente 1 min más el TTL, correo entrante sin espera a través del segundo MX o del reintento del remitente. Estos tiempos se derivan de los valores predeterminados, no se han medido: verifíquelos en su propia prueba (detenga un sitio de forma abrupta).
netcup (sitio B, réplica) queda fuera de línea
El primario en A sigue funcionando. Sin un socio síncrono, Patroni pasa a modo asíncrono (strict = false), el trabajo de failover elimina la IP B y el correo entrante fluye por mx1. Garage continúa con A y C.
Solo está caído el enlace entre A y B
El lado que todavía alcanza al testigo C conserva la mayoría de etcd y sigue siendo o pasa a ser primario. El otro lado pierde su mayoría, Patroni degrada allí a un primario (failsafe_mode solo se aplica si todavía puede alcanzar a todos los miembros) y el failover de DNS no actúa allí: el agente integrado no alcanza ningún primario y no obtiene el bloqueo de líder, y el script no obtiene el bloqueo de etcd. No se produce ningún 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
Cuando A vuelve, B sigue siendo primario. Patroni reincorpora A como réplica con pg_rewind (use_pg_rewind: true), Garage resincroniza los objetos que faltan y el trabajo de failover vuelve a añadir la IP A en cuanto /health/ready informa allí "ready". No es necesario devolver el primario; si lo desea, hágalo de forma planificada con patronictl switchover en un momento tranquilo.
09 · Copia de seguridad, restauración y GoBD
La replicación protege frente a caídas, no frente a errores: un buzón eliminado por accidente desaparece en todos los sitios a la vez. Por eso, añada copias de seguridad según la regla 3-2-1: tres copias, dos tipos de soporte, una fuera de las instalaciones. [59]
flowchart LR
PG[("PostgreSQL primary or replica")] -->|"pgBackRest, continuous WAL"| R1["Repo 1: Hetzner Storage Box (SFTP)"]
PG -->|"pgBackRest"| R2["Repo 2: S3 at another provider"]
GA["Garage (3 zones)"] -->|"rclone copy hourly"| AR["Object Storage with Object Lock (GoBD)"]
GA -->|"rclone copy"| R1
R1 --> T["Quarterly restore test"]
R2 --> T
- PostgreSQL: pgBackRest con archivado continuo de WAL (recuperación a un punto en el tiempo). Repositorio 1 en una Hetzner Storage Box mediante SFTP (pgBackRest admite SFTP desde la 2.46; la Storage Box ofrece SFTP, SCP, rsync, BorgBackup, Restic, Rclone, SMB, WebDAV), repositorio 2 en S3 en otro proveedor. Ambos cifrados. [58][15]
- Blobs: los objetos .eml se nombran por su SHA-256 y nunca se modifican.
rclone copy(no sync, para que nunca elimine nada) basta como copia cada hora. - Archivo GoBD: Hetzner Object Storage admite Object Lock en modo GOVERNANCE y COMPLIANCE, además de versionado; Object Lock debe activarse al crear el bucket. Cree el bucket con una retención predeterminada (compliance); así, cada objeto copiado queda bloqueado automáticamente. Como alternativa, deje que nextxch escriba directamente en este bucket (backend S3 o híbrido), aunque entonces la disponibilidad de los blobs depende de Hetzner. En ese caso, nextxch crea al iniciarse un bucket que falte con Object Lock en cuanto se configura un modo WORM (o
Storage:S3:ObjectLockEnabled); "Comprobar bucket S3" en el archivo GoBD muestra la accesibilidad, el versionado, el modo de Object Lock y la retención predeterminada. [18][17] - Pruebas de restauración: al menos trimestralmente, restaure pgBackRest en una VM de prueba y abra muestras de la copia 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 · Escalado: usuarios, almacenamiento, 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 | Nota |
|---|---|---|
| Nodos / pods de nextxch | horizontalmente | Sin estado; más nodos por sitio o más pods (HPA). No se requieren sesiones persistentes (sticky sessions). |
| Primario de PostgreSQL | verticalmente | Todas las escrituras van a un único primario. Más RAM y discos más rápidos (IOPS) en lugar de más nodos; valor inicial de shared_buffers: 25 % de la RAM. nextxch no utiliza réplicas de lectura. [57] |
| Almacén de blobs | horizontalmente | Más nodos Garage por zona con capacidad (garage layout assign -c); los datos se reequilibran. La deduplicación SHA-256 y la compresión Zstd reducen la necesidad. |
| SMTP | mediante DNS | Más registros MX con la misma preferencia reparten la carga; la cola saliente se reparte entre todos los nodos mediante SKIP LOCKED. |
| IMAP | mediante DNS / LB | Las conexiones IDLE de larga duración permanecen en su nodo; el correo nuevo de otros nodos llega mediante LISTEN/NOTIFY. Tras añadir un nodo, la carga solo se reparte a medida que los clientes se reconectan. [7] |
| HTTPS (webmail, ActiveSync, JMAP) | mediante DNS / LB | Sesiones HMAC sin estado; cualquier solicitud puede llegar a cualquier nodo. |
11 · Costes: dónde ahorrar y dónde no
Guardar
- Planes de vCPU compartida para nextxch y el testigo; núcleos dedicados solo cuando la carga de CPU de la base de datos lo indique.
- Plazo de 12 meses en netcup (13 % por debajo del precio mensual según la página de precios). [22]
- Failover de DNS con el agente integrado (o su propio script) en lugar de un balanceador de carga de pago.
- Compresión Zstd (Storage:Compression) y deduplicación SHA-256 para blobs.
No ahorre en
- RAM de los nodos PostgreSQL: todo lo que no está en caché cuesta E/S de disco en cada vista de carpeta.
- IOPS de disco para PostgreSQL (NVMe); las escrituras WAL determinan la latencia de cada entrega.
- El tercer sitio: sin él no hay failover automático seguro.
- Copias de seguridad probadas en un destino independiente.
Precios registrados (netos al mes, a 2 de noviembre de 2026). netcup y STRATO indican precios brutos con un 19 % de IVA incluido, aquí divididos entre 1,19. En Hetzner se suman 0,50 € por la IPv4 principal. En la fecha de comprobación, Hetzner mostraba todos los planes cloud como "actualmente no disponibles"; no se pudo determinar si se debía a las existencias o a la visualización. El precio de los volúmenes de Hetzner no pudo leerse en las páginas oficiales, por lo que falta en la calculadora. Los precios cambian: compruébelos con el proveedor antes de hacer el pedido. [12][10][22][23][30][15][16]
| Proveedor | Plan | vCPU | RAM | Disco | € netos / mes |
|---|---|---|---|---|---|
| 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 | precio base incl. 1 TB de almacenamiento + 1 TB de tráfico saliente | - | - | 1 TB | €6.49 |
12 · Calculadora: recursos y costes
Se ejecuta íntegramente en el navegador, sin servicios externos. Los supuestos se indican abiertamente debajo del resultado.
Supuestos (no medidos, compruébelos con sus propias cifras)
- Volumen de correo = buzones × tamaño medio × (1 + crecimiento)^años. Sin descuento por deduplicación ni compresión (conservador).
- Tamaño de PostgreSQL = 3 % del volumen de correo (metadatos, marcas, índice de texto completo), al menos 2 GB. El valor real se obtiene con
SELECT pg_database_size('nextxch'). - RAM para PostgreSQL = 25 % del tamaño de la BD, como mínimo 2 GB, redondeado hacia arriba a 2/4/8/16/32/64/96/128 GB. Disco para PostgreSQL = 2 × tamaño de la BD (datos, WAL, margen).
- Proceso nextxch: 1 GB + 1 GB por cada 1.000 buzones iniciados (el límite de Kubernetes en el repositorio es 1 GiB por pod). etcd + Garage + sistema operativo: 1 GB. Testigo: 2 GB de RAM. Sistema operativo: 20 GB de disco.
- Garage replication_factor 3: cada zona (sitio o testigo) almacena el volumen de correo completo.
- Configuración L: al menos 2 pods por proveedor, 1 pod por cada 1500 buzones, workers con 8 GB; la VM de datos aloja PostgreSQL, etcd y la zona de Garage.
- Necesidad de copia de seguridad = 3 × tamaño de la BD (2 copias completas + WAL) + volumen de correo; objetivo: la Hetzner Storage Box más pequeña que encaje.
- Precios de licencia del catálogo de precios actual de este sitio web (el precio del tramo se aplica a todos los buzones; precio anual / 12). Cloudflare se muestra en USD y no se suma al total en euros.
13 · Añadir otro proveedor (ejemplo: STRATO)
- Antes de contratar: ¿se puede configurar el PTR? STRATO: DNS inverso para VPS en el área de cliente, en dominios; primero debe existir un registro A/AAAA y tarda hasta 24 h. Puerto 25: para servidores root, STRATO documenta el tráfico saliente como bloqueado, con desbloqueo a través del soporte; para VPS no hay ninguna regla documentada. STRATO no ofrece IP de failover. [31][32][30]
- Añada el nuevo host al inventario de Ansible con su propia IP de WireGuard y su zona de Garage, añada el peer en todos los sitios y ejecute el playbook.
- etcd: añada el miembro con
etcdctl member adde inícielo con ETCD_INITIAL_CLUSTER_STATE=existing. Con cuatro sitios de base de datos, mantenga impar el número de miembros de etcd. [48] - Patroni se inicia como réplica y replica automáticamente. Garage:
garage layout assigncon la nueva zona,garage layout apply. - DNS: añada la IP a SPF, un nuevo MX con la misma preferencia, el nodo a la lista de failover de DNS (agente o script), un registro TLSA para el nuevo nombre MX, el nombre EHLO y el PTR del nuevo nodo, y amplíe el certificado con el nombre. La política MTA-STS incorpora el nuevo MX automáticamente (lista MX automática); a continuación, publique el nuevo valor TXT de _mta-sts desde el asesor DNS.
- Añada el sitio a la matriz de la canalización y pruebe una vez la caída del nuevo sitio.
14 · Límites y carencias (con honestidad)
Detectado en el código al redactar esta guía. Cuatro carencias ya están resueltas: MTA-STS con varios nombres MX, nombre EHLO por nodo, bucket con Object Lock creado automáticamente y el agente de failover de DNS integrado. La guía soluciona el resto tal como se describe.
| Carencia | Impacto | Solución alternativa |
|---|---|---|
| Agente de failover de DNS (cerrado) | Integrado, opcional (opt-in): comprobaciones de estado desde cada nodo; solo el líder del clúster cambia A/AAAA mediante Hetzner DNS o Cloudflare. | Otros proveedores de DNS (p. ej., deSEC): script bajo bloqueo de etcd (capítulo 04). |
| MTA-STS con varios MX (resuelto) | La política por dominio nombra todos los nombres MX (MX de DNS, nombres de servidor y de nodo, o una lista propia con comodines); el id de la política sigue a la lista. | Tras cada cambio de MX, publique el valor TXT _mta-sts del asesor DNS. |
| Nombre EHLO por nodo (resuelto) | Smtp:HeloHostname o un ajuste por nodo en /admin/, con comprobación de PTR por nodo; el nombre de host del servidor sigue siendo válido para todo el clúster. | - |
| Solo un destino S3 | Sin escritura doble en dos almacenes S3 de proveedores distintos; las funciones de Object Lock requieren un S3 con Object Lock. | Garage en tres sitios más rclone copy a un bucket con Object Lock. |
| Bucket con Object Lock (cerrado) | Con un modo WORM configurado, el servidor crea al arrancar un bucket que falte con Object Lock. Un bucket existente sin Object Lock se notifica como advertencia y alerta, porque Object Lock solo puede activarse al crearlo. [18] | Compruébelo en cualquier momento con "Check S3 bucket" en el archivo GoBD. |
| Sin copia de seguridad integrada | El servidor no incluye ningún servicio de copia de seguridad para la base de datos y los blobs. | pgBackRest y rclone (capítulo 09). |
| Sin réplicas de lectura | Todas las consultas van al primario; las réplicas de lectura figuran en el plan de desarrollo solo como "bajo solicitud". | Escale verticalmente el primario de PostgreSQL. |
| Sin benchmark de dimensionamiento medido | Los tamaños de RAM y de base de datos de la calculadora son supuestos. | Sustitúyalo por sus propias mediciones. |
Preguntas frecuentes
¿Puede nextxch funcionar en activo-activo en varios proveedores?
Sí. Con Cluster:Enabled=true y PostgreSQL, cualquier número de nodos nextxch comparten el estado mediante LISTEN/NOTIFY, tablas UNLOGGED y advisory locks. Todos los nodos deben alcanzar el mismo primario PostgreSQL y el mismo almacén de blobs S3; entre proveedores, esto se hace a través de una red WireGuard.
¿Necesito un balanceador de carga o anycast?
No. El correo entrante solo necesita varios registros MX con la misma preferencia (RFC 5321, sección 5.1). IMAP, el envío y HTTPS necesitan un nombre con un TTL corto cuyos registros A mantiene un trabajo de comprobación de estado. Anycast/BGP es posible, pero complejo.
¿Cuántos sitios necesito como mínimo?
Tres votos: dos sitios completos más un pequeño testigo (etcd, zona de Garage, comprobador de failover), o tres sitios completos. Con solo dos sitios, ningún lado puede saber con seguridad si el otro ha fallado o si solo se ha cortado el enlace (split brain).
¿Se pierde correo cuando un proveedor queda fuera de línea?
Los remitentes siguen reintentando durante al menos 4 a 5 días (RFC 5321, sección 4.5.4.1). Con Patroni synchronous_mode y Garage replication_factor 3, un mensaje confirmado con 250 OK ya está almacenado en un segundo sitio.
15 · Fuentes
Todos los datos externos se comprobaron el 2 de noviembre de 2026 con las siguientes fuentes oficiales. Los puntos que no pudieron verificarse se señalan como tales en el texto.
- RFC 5321 (SMTP): 4.1.4 EHLO, 4.5.4.1 reintento, 5.1 MX: https://www.rfc-editor.org/rfc/rfc5321.html
- RFC 7208 (SPF): 4.6.4 límite de consultas DNS, 5.6 ip4/ip6: https://www.rfc-editor.org/rfc/rfc7208.html
- RFC 6376 (DKIM): 3.1 selectores: https://www.rfc-editor.org/rfc/rfc6376.html
- RFC 8461 (MTA-STS): 3.2, 4.1 patrones 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, renovar cada 29 minutos): https://www.rfc-editor.org/rfc/rfc2177.html
- Google: directrices para remitentes de correo (DNS directo e inverso): https://support.google.com/a/answer/81126
- Hetzner Docs: Floating IPs: https://docs.hetzner.com/cloud/floating-ips/overview
- Hetzner Docs: precios de IPv4: https://docs.hetzner.com/general/others/ipv4-pricing
- Documentación de Hetzner: IP de failover de Robot: https://docs.hetzner.com/robot/dedicated-server/ip/failover
- Hetzner Cloud: planes optimizados en coste: https://www.hetzner.com/cloud/cost-optimized/
- Hetzner Docs: FAQ de servidores cloud (puertos 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 (precios, protocolos): https://www.hetzner.com/storage/storage-box/
- Hetzner Object Storage (precios): https://www.hetzner.com/storage/object-storage/
- Documentación de Hetzner: visión general de Object Storage: https://docs.hetzner.com/storage/object-storage/overview
- Documentación de Hetzner: Object Lock y retención: https://docs.hetzner.com/storage/object-storage/howto-protect-objects/protect-object-lock-retention/
- Documentación de Hetzner: preguntas frecuentes sobre Load Balancer: https://docs.hetzner.com/cloud/load-balancers/faq
- Hetzner Docs: FAQ de redes: https://docs.hetzner.com/cloud/networks/faq
- Documentación de Hetzner: DNS (Console y API): https://docs.hetzner.com/networking/dns/overview/
- netcup: precios de VPS G12.5: https://www.netcup.com/de/server/vps
- netcup: precios de Root Server G12.5: https://www.netcup.com/de/server/root-server
- Centro de ayuda de netcup: direcciones IP y failover: https://www.netcup.com/en/helpcenter/documentation/server/ip
- netcup: ampliaciones de servidor (precios 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: red / 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: precios de VPS Linux: https://www.strato.de/server/linux-vserver/
- STRATO FAQ: DNS inverso para VPS: https://www.strato.de/faq/server/wie-kann-ich-reverse-dns-fuer-meinen-vps-vds-einrichten/
- FAQ de STRATO: firewall de red del servidor root (puerto 25): https://www.strato.de/faq/server/wie-stelle-ich-die-netzwerk-firewall-bei-meinem-windows-root-server-ein/
- Cloudflare: precio del complemento Load Balancing: https://www.cloudflare.com/plans/network-cdn/
- Cloudflare Docs: limitaciones de Load Balancing (intervalo de comprobación de estado): https://developers.cloudflare.com/load-balancing/reference/limitations/
- Cloudflare Docs: registros DNS de Load Balancing (MX/SRV solo en Enterprise): https://developers.cloudflare.com/load-balancing/load-balancers/dns-records/
- Cloudflare Docs: modos de proxy (solo DNS): https://developers.cloudflare.com/load-balancing/understand-basics/proxy-modes/
- Cloudflare Docs: protocolos de Spectrum por plan: https://developers.cloudflare.com/spectrum/protocols-per-plan/
- Documentación de Cloudflare: DNS TTL: https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/
- API de Cloudflare: editar registro DNS: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
- deSEC: dominios y TTL mínimo: https://desec.readthedocs.io/en/latest/dns/domains.html
- Akamai/Linode: failover de IP (BGP, mismo centro de datos): https://techdocs.akamai.com/cloud-computing/docs/configure-failover-on-a-compute-instance
- Akamai: precios de NodeBalancer: https://www.akamai.com/cloud/pricing/nodebalancers
- Vultr: requisitos de BGP: https://docs.vultr.com/configuring-bgp-on-vultr
- RIPE NCC: solicitud de un número AS: https://www.ripe.net/manage-ips-and-asns/as-numbers/request-an-as-number/
- Proxmox VE: Cluster Manager (latencia de 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: alta disponibilidad con etcd integrado: https://docs.k3s.io/datastore/ha-embedded
- k3s: clúster distribuido híbrido o multinube: 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: clústeres réplica: https://cloudnative-pg.io/docs/current/replica_cluster
- Patroni: configuración dinámica (ttl, loop_wait, retry_timeout): https://patroni.readthedocs.io/en/latest/dynamic_configuration.html
- Patroni: modos de replicación (synchronous_mode, strict): https://patroni.readthedocs.io/en/latest/replication_modes.html
- Patroni: modo failsafe del DCS: https://patroni.readthedocs.io/en/latest/dcs_failsafe_mode.html
- PostgreSQL 18: replicación 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 (repositorios S3 y 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 del proyecto (multicentro de datos, 200 ms / 50 Mbps): https://garagehq.deuxfleurs.fr/
- Garage: configuración (replication_factor, quórums): https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
- Garage: compatibilidad con S3 (sin Object Lock, sin control de versiones): https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/
- Npgsql: failover y balanceo de carga (multihost): https://www.npgsql.org/doc/failover-and-load-balancing.html
- Mermaid 12.2.0 (MIT), incluido en este sitio: https://www.npmjs.com/package/mermaid