Montar un servidor de correo con Stalwart en un VPS, paso a paso

Guía práctica para desplegar Stalwart y SnappyMail en un VPS, publicar el DNS correcto, activar TLS y comprobar la entrega real.

¡Muy buenas!

Hoy vamos a montar un servidor de correo completo para vbits.me, desde un VPS recién preparado hasta el momento en que un mensaje sale por SMTP directo y llega a Proton Mail.

La idea es que esta guía sea una receta reproducible. Si utilizas otro dominio, cambia vbits.me, los hostnames, la IP pública y las contraseñas; el resto de la estructura es la misma.

Al final tendremos Stalwart para el correo, SnappyMail como webmail, Nginx Proxy Manager para HTTPS y SPF, DKIM, DMARC y TLS correctamente configurados.

Y una petición amistosa: úsalo para tu correo y no para hacer spam, que luego la reputación la pagamos todos. 😉

1. Valores de esta instalación

Dominio:             vbits.me
Correo:              mail.vbits.me
Webmail:             webmail.vbits.me
Panel privado:       mailadmin.vbits.me
IP pública:          94.143.142.166
DNS autoritativo:    IONOS
Proveedor VPS:       Piensa Solutions
Stalwart:            v0.16 Community

El panel administrativo se publica solo por la VPN. No expongas la administración de Stalwart directamente a Internet.

2. Preparar el VPS

Esta guía usa Debian/Ubuntu y Docker. Si Docker ya está instalado, puedes saltar al apartado de puertos.

sudo apt update
sudo apt install -y ca-certificates curl gnupg nftables openssl dnsutils
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg \
  | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
docker version
docker compose version

Comprobamos que no haya otro MTA ocupando los puertos:

sudo ss -ltnp | grep -E ':(25|465|587|993|4190)\b' || true
sudo systemctl disable --now postfix  # solo si no se va a conservar Postfix

3. Firewall y activación del puerto 25

Antes de tocar nftables guarda una copia:

sudo cp -a /etc/nftables.conf \
  /etc/nftables.conf.before-mail-$(date +%Y%m%d)

Con una política drop, permite TCP 25, 465, 587, 993, 4190, además de SSH, WireGuard y HTTP/HTTPS según tu arquitectura. Si Docker publica los puertos, permite también el tráfico correspondiente en forward.

Valida la configuración antes de cargarla:

sudo nft -c -f /etc/nftables.conf

El firewall del proveedor es independiente. Hay que solicitar a Piensa la salida SMTP por TCP/25:

Necesito activar el envío SMTP saliente por TCP/25 para mi VPS. Se utilizará para un dominio propio con SPF, DKIM y DMARC. También necesito configurar el PTR de la IP para que apunte a mail.vbits.me.

Comprueba la salida desde el VPS, no solo la entrada:

nc -vz -w 10 gmail-smtp-in.l.google.com 25
nc -vz -w 10 mail.protonmail.ch 25

4. DNS en IONOS

La zona de vbits.me se gestiona directamente en IONOS. Crea estos registros:

A    mail       94.143.142.166
A    webmail    94.143.142.166
MX   @          5 mail.vbits.me.
TXT  @          v=spf1 ip4:94.143.142.166 include:_spf-eu.ionos.com ~all
TXT  _dmarc     v=DMARC1; p=none; rua=mailto:admin@vbits.me; adkim=s; aspf=s; pct=100

Debe existir un único SPF. El include conserva la autorización de IONOS mientras comprobamos si todavía hay otros servicios que envían mensajes.

Stalwart genera DKIM. Publica exactamente los TXT que muestre el panel. En esta instalación los selectores son:

dig +short TXT v1-ed25519-20261023._domainkey.vbits.me
dig +short TXT v1-rsa-20261023._domainkey.vbits.me

No reutilices las claves DKIM de otra instalación: cada despliegue puede generar selectores diferentes.

5. Crear las redes Docker

docker network create mail 2>/dev/null || true
docker network inspect npm_frontend >/dev/null 2>&1 || \
  echo 'La red npm_frontend debe existir antes de continuar'

mail será la red interna de correo. npm_frontend permite que Nginx Proxy Manager llegue al webmail.

6. Desplegar Stalwart

sudo mkdir -p /opt/stalwart/{config,data}
cd /opt/stalwart

Guarda este docker-compose.yml:

services:
  stalwart:
    image: stalwartlabs/stalwart:v0.16
    container_name: stalwart
    restart: unless-stopped
    environment:
      STALWART_PUBLIC_URL: https://mail.vbits.me
    ports:
      - "25:25"
      - "465:465"
      - "587:587"
      - "993:993"
      - "4190:4190"
      - "<IP_PRIVADA_VPN>:8080:8080"
      - "<IP_PRIVADA_VPN>:8443:443"
    volumes:
      - ./config:/etc/stalwart
      - ./data:/var/lib/stalwart
    networks:
      - mail
      - npm_frontend
    logging:
      driver: json-file
      options:
        max-size: 10m
        max-file: "3"

networks:
  mail:
    external: true
  npm_frontend:
    external: true

La IP <IP_PRIVADA_VPN> es la interfaz VPN usada en este VPS. Sustitúyela por tu interfaz de administración; no publiques 8080 ni 8443 en Internet.

docker compose config --quiet
docker compose up -d
docker ps --filter name=stalwart
docker logs --since 5m stalwart

En el panel privado crea la cuenta administrativa, el dominio vbits.me, las cuentas de correo y las claves DKIM:

https://mailadmin.vbits.me/admin

7. Configurar los listeners

En Settings → Network → Listeners comprobamos estos listeners:

Nombre Puerto Protocolo TLS
smtp 25 SMTP STARTTLS opcional
submissions 465 SMTP TLS implícito
submission 587 SMTP STARTTLS
imaps 993 IMAP TLS implícito
sieve 4190 ManageSieve STARTTLS
ss -ltnp | grep -E ':(25|465|587|993|4190)\b'

En Stalwart v0.16 algunos listeners pueden estar guardados pero no aplicados al proceso hasta recargar o recrear el contenedor.

8. Desplegar SnappyMail

sudo mkdir -p /opt/snappymail/data
cd /opt/snappymail

docker-compose.yml:

services:
  snappymail:
    image: djmaze/snappymail@sha256:cc053169c76e27d54c3533b002ec9987786ca50bf9265ae318b44d8699f60ec0
    container_name: snappymail
    restart: unless-stopped
    expose:
      - "8888"
    volumes:
      - ./data:/var/lib/snappymail
    networks:
      - mail
      - npm_frontend

networks:
  mail:
    external: true
  npm_frontend:
    external: true
docker compose config --quiet
docker compose up -d
docker ps --filter name=snappymail

En Nginx Proxy Manager crea un Proxy Host:

Domain Names: webmail.vbits.me
Forward Hostname: snappymail
Forward Port: 8888
Websockets Support: activado

Solicita un certificado para webmail.vbits.me, activa Force SSL y prueba:

curl -I https://webmail.vbits.me

En SnappyMail configura el dominio así:

IMAP:   stalwart:993   IMAPS
SMTP:   stalwart:465   SMTPS y autenticación
Sieve:  stalwart:4190  STARTTLS

Prueba el inicio de sesión y un envío local antes de probar la entrega externa.

9. Certificado TLS para el correo

El certificado de webmail.vbits.me no sirve automáticamente para SMTP. Emitimos otro para mail.vbits.me usando el webroot HTTP de Nginx Proxy Manager:

sudo apt install -y certbot
sudo certbot certonly \
  --webroot \
  -w /opt/nginx-proxy-manager/data/letsencrypt-acme-challenge \
  -d mail.vbits.me \
  --email admin@vbits.me \
  --agree-tos \
  --no-eff-email \
  --non-interactive \
  --keep-until-expiring

Comprobamos el certificado sin mostrar nunca la clave privada:

sudo certbot certificates
sudo openssl x509 \
  -in /etc/letsencrypt/live/mail.vbits.me/fullchain.pem \
  -noout -subject -issuer -dates -ext subjectAltName

Añade al Compose de Stalwart los montajes de solo lectura:

      - /etc/letsencrypt/live/mail.vbits.me:/etc/letsencrypt/live/mail.vbits.me:ro
      - /etc/letsencrypt/archive/mail.vbits.me:/etc/letsencrypt/archive/mail.vbits.me:ro

Conserva una copia, valida y recrea el servicio:

cp -a docker-compose.yml docker-compose.yml.before-tls
docker compose config --quiet
docker compose up -d --force-recreate stalwart

Si el contenedor usa UID 2000, concede solo lectura a la clave del certificado:

sudo setfacl -m u:2000:r /etc/letsencrypt/archive/mail.vbits.me/privkey*.pem
sudo setfacl -m d:u:2000:r,d:m:r /etc/letsencrypt/archive/mail.vbits.me

En Settings → TLS → Certificates crea un certificado con:

Certificate: /etc/letsencrypt/live/mail.vbits.me/fullchain.pem
Private key: /etc/letsencrypt/live/mail.vbits.me/privkey.pem

Solicita la recarga TLS desde la administración y verifica:

openssl s_client -starttls smtp -connect mail.vbits.me:25 \
  -servername mail.vbits.me </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
openssl s_client -connect mail.vbits.me:465 \
  -servername mail.vbits.me </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
openssl s_client -connect mail.vbits.me:993 \
  -servername mail.vbits.me </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

La renovación de Certbot debe incluir una recarga de certificados de Stalwart mediante un deploy hook. No copies claves privadas al repositorio ni al artículo.

10. PTR y reputación

El PTR lo configura Piensa, no IONOS. En esta instalación ya está asignado por el proveedor:

94.143.142.166 → mail.vbits.me

La resolución inversa se verifica desde fuera del VPS:

dig +short -x 94.143.142.166

Debe devolver mail.vbits.me.. El banner SMTP y el PTR ya coinciden.

El PTR no cambia webs ni enrutamiento; solo establece el nombre inverso de la IP. Una IP solo puede tener un PTR.

Una entrada UCEPROTECT de nivel 3 suele referirse al rango del proveedor. No conviene pagar una eliminación automática sin pedir primero una revisión a Piensa.

11. Verificación de entrega

dig +short MX vbits.me
dig +short A mail.vbits.me
dig +short TXT vbits.me
dig +short TXT _dmarc.vbits.me
dig +short TXT v1-ed25519-20261023._domainkey.vbits.me
dig +short -x 94.143.142.166

nc -vz -w 10 mail.vbits.me 25
nc -vz -w 10 mail.vbits.me 465
nc -vz -w 10 mail.vbits.me 587
nc -vz -w 10 mail.vbits.me 993
nc -vz -w 10 mail.vbits.me 4190

Desde SnappyMail envía a Gmail, Outlook y Proton Mail. En los encabezados del mensaje recibido comprueba:

spf=pass
dkim=pass
dmarc=pass

La prueba final es que el MX remoto responda 250 Ok y que la cola remota de Stalwart quede vacía.

12. Reintentos y mensajes pendientes

Un timeout no elimina un mensaje. Stalwart lo conserva y aplica reintentos progresivos hasta entregarlo, generar un rebote o alcanzar la caducidad.

La programación de reintentos y la expiración deben revisarse juntas. En este montaje, después de varios fallos por el puerto 25 bloqueado, el siguiente reintento quedó programado después de la caducidad.

Cuando se corrige la causa, se puede adelantar el siguiente reintento desde Management → Emails → Queued. Esto no borra el mensaje.

13. DMARC: observar y endurecer

Durante los primeros días mantenemos p=none y revisamos los informes rua. Si solo aparecen emisores esperados y SPF/DKIM están alineados, pasamos a:

v=DMARC1; p=quarantine; rua=mailto:admin@vbits.me; adkim=s; aspf=s; pct=100

Después, si todo continúa correcto:

v=DMARC1; p=reject; rua=mailto:admin@vbits.me; adkim=s; aspf=s; pct=100

BIMI es opcional. Requiere quarantine o reject al 100 %, además del registro BIMI y, para una aceptación amplia, la certificación del logo.

14. Problemas encontrados

  • Piensa bloqueaba inicialmente la salida por el puerto 25.
  • Dos mensajes quedaron en cola mientras el bloqueo estaba activo.
  • El siguiente reintento quedaba después de la caducidad y se adelantó manualmente.
  • El PTR genérico del proveedor no coincidía inicialmente con el banner SMTP; Piensa lo actualizó a mail.vbits.me.
  • El primer certificado TLS de Stalwart era autofirmado.
  • SPF y DMARC no existían al principio y se añadieron desde IONOS.

15. Checklist final

  • [x] MX apuntando a mail.vbits.me.
  • [x] A de mail.vbits.me correcto.
  • [x] PTR coincidente con mail.vbits.me.
  • [x] SPF publicado.
  • [x] DKIM publicado.
  • [x] DMARC en modo none.
  • [x] Certificado TLS válido.
  • [x] SMTP, SMTPS, submission, IMAPS y ManageSieve activos.
  • [x] Salida directa por el puerto 25.
  • [x] Entrega real probada.
  • [x] Cola vacía después de entregar.
  • [x] Renovación TLS automatizada.

16. Proteger la administración de WordPress

El sitio público puede seguir siendo accesible desde Internet sin dejar expuesto el panel de WordPress. En Nginx Proxy Manager restringimos estas rutas del proxy de vbits.me a la red WireGuard 10.8.0.0/24:

/wp-login.php
/wp-admin/

Desde Internet ambas rutas devuelven 403. Al conectarse a WireGuard, el formulario de login vuelve a estar disponible y /wp-admin/ puede redirigir al login si todavía no hay una sesión autenticada.

La excepción /wp-admin/admin-ajax.php permanece pública porque el tema utiliza esa ruta para algunas funciones del frontend. No concede acceso al panel y una petición sin acción devuelve 400.

Después de cambiar la configuración hay que validar Nginx y probar los dos caminos:

docker exec nginx-proxy-manager nginx -t
curl -I https://vbits.me/wp-login.php
curl -I --resolve vbits.me:443:10.8.0.1 https://vbits.me/wp-login.php

Despedida

Y con esto ya tenemos un servidor de correo propio, con webmail, cifrado, autenticación DNS y entrega directa. La parte divertida es verlo funcionar; la parte responsable es vigilar los informes, mantener todo actualizado y no convertirlo en una máquina de spam.

¡Que lo disfrutes y que tus correos lleguen a la bandeja de entrada! 😉

Referencias