Serveur mail auto-hébergé : VPS cache + stockage local via WireGuard

Conteneurs Docker Dovecot Postfix SSL Snappymail Sécurité Traefik VPN WireGuard fail2ban
Serveur mail auto-hébergé : VPS cache + stockage local via WireGuard

J'ai mis à peu près deux semaines à mettre ça en place. Pas deux semaines full time, mais deux semaines où ça traînait dans un coin de ma tête avec une liste de trucs qui bloquaient. Le reverse DNS que j'avais oublié. Le port 25 bloqué par défaut chez IONOS (il faut appeler le support). Le chown à faire pour que Dovecot puisse lire ce que mbsync crée en root.

Ce guide documente l'infra que j'utilise aujourd'hui : un VPS IONOS comme cache SMTP exposé sur internet, un tunnel WireGuard, et tout le stockage des mails sur ma machine à la maison. L'idée c'est que le VPS ne garde jamais les mails longtemps — mbsync les pull toutes les 5 minutes et les efface du VPS après. Ce qui reste exposé sur internet, c'est juste le cache.

Architecture globale

Trois couches : le VPS exposé, le tunnel VPN, et le homeserver fermé depuis l'extérieur.

Internet (Gmail, autres serveurs mail)
        │
        ▼ port 25 (SMTP entrant)
┌─────────────────────────────────────┐
│  VPS IONOS (IP publique)            │
│  Postfix (réception SMTP)           │
│  Dovecot (stockage temporaire)      │
│  fail2ban + nftables                │
└─────────────────────────────────────┘
        │  WireGuard VPN (port 993 uniquement)
        ▼
┌─────────────────────────────────────┐
│  Machine maison (homeserver)        │
│  mbsync (pull IMAP toutes 5min)     │
│  Dovecot local (IMAP local)         │
│  Snappymail (webmail LAN)           │
│  Stockage Maildir permanent         │
└─────────────────────────────────────┘
Composant Rôle Localisation
Postfix Réception SMTP port 25 VPS IONOS
Dovecot (cache) Stockage temporaire IMAP VPS IONOS
fail2ban + nftables Blackhole IP abusives VPS IONOS
WireGuard Tunnel sécurisé VPS ↔ maison Les deux
mbsync Pull des mails IONOS → maison homeserver
Dovecot (local) Serveur IMAP local homeserver
Snappymail Webmail accessible sur LAN homeserver

Configuration DNS OVH

Dans la zone DNS OVH, deux enregistrements suffisent pour commencer :

Enregistrement A — pointer le sous-domaine mail vers le VPS

mail    IN    A    <IP_PUBLIQUE_VPS>

Enregistrement MX — pointer le domaine vers le serveur mail

@    IN    MX    1    mail.example.com.

⚠️ Mettez le TTL à 300 secondes (5 min) le temps des tests, puis augmentez à 3600 après validation.

dig MX example.com +short @8.8.8.8
dig A mail.example.com +short @8.8.8.8

Stack mail sur VPS IONOS

On utilise docker-mailserver qui embarque Postfix + Dovecot + fail2ban + SpamAssassin dans un seul conteneur.

ℹ️ Le port 25 entrant peut être bloqué par défaut chez IONOS. Il faut le débloquer via le panel firewall IONOS ou contacter le support.

Stack docker-compose (Portainer)

version: "3.9"

services:
  mail-cert:
    image: traefik/whoami
    container_name: mail-cert
    restart: unless-stopped
    networks:
      - web
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.mail-cert.rule=Host(`mail.example.com`)"
      - "traefik.http.routers.mail-cert.entrypoints=websecure"
      - "traefik.http.routers.mail-cert.tls=true"
      - "traefik.http.routers.mail-cert.tls.certresolver=letsencrypt"
      - "traefik.http.routers.mail-cert.middlewares=block-all"
      - "traefik.http.middlewares.block-all.ipallowlist.sourcerange=127.0.0.1/32"

  mailserver:
    image: ghcr.io/docker-mailserver/docker-mailserver:latest
    container_name: mailserver
    hostname: mail.example.com
    ports:
      - "25:25"
      - "<IP_WIREGUARD_VPS>:993:993"  # IMAP sur WireGuard uniquement
    volumes:
      - /home/mail/data:/var/mail
      - /home/mail/state:/var/mail-state
      - /home/mail/logs:/var/log/mail
      - /home/mail/config:/tmp/docker-mailserver/
      - /home/mail/certs:/certs:ro
      - /etc/localtime:/etc/localtime:ro
    environment:
      - SSL_TYPE=manual
      - SSL_CERT_PATH=/certs/mail.example.com/certificate.crt
      - SSL_KEY_PATH=/certs/mail.example.com/privatekey.key
      - POSTFIX_INET_PROTOCOLS=ipv4
      - ENABLE_FAIL2BAN=1
      - FAIL2BAN_BLOCKTYPE=drop
      - ENABLE_IMAP=1
    cap_add:
      - NET_ADMIN
      - SYS_PTRACE
    restart: unless-stopped

networks:
  web:
    external: true
    name: traefik_web

Créer la première boîte mail

docker exec -it mailserver setup email add user@example.com

Certificats TLS via Traefik

Traefik gère les certificats Let's Encrypt via son acme.json. Un script Python extrait les certificats pour docker-mailserver — à mettre dans un cron qui tourne après chaque renouvellement.

# /home/mail/extract-certs.py
import json, base64, os

with open('/opt/docker/traefik/letsencrypt/acme.json') as f:
    data = json.load(f)

for resolver in data.values():
    for cert in resolver.get('Certificates', []):
        domain = cert['domain']['main']
        os.makedirs(f'/home/mail/certs/{domain}', exist_ok=True)
        with open(f'/home/mail/certs/{domain}/certificate.crt', 'wb') as f:
            f.write(base64.b64decode(cert['certificate']))
        with open(f'/home/mail/certs/{domain}/privatekey.key', 'wb') as f:
            f.write(base64.b64decode(cert['key']))
        print(f'Extrait : {domain}')
python3 /home/mail/extract-certs.py

Renouvellement automatique (cron)

echo "0 3 * * * root python3 /home/mail/extract-certs.py && docker restart mailserver" >> /etc/cron.d/mail-certs

Protection fail2ban + nftables

docker-mailserver intègre fail2ban avec nftables-allports — les IPs bannies sont bloquées sur tous les ports via une table de hash kernel. O(1), ça supporte des centaines de milliers d'entrées sans sourciller. J'ai mis le ban permanent à 2 tentatives : en pratique, un serveur légitime ne rate jamais deux fois l'authentification de suite.

# /home/mail/config/fail2ban/jail.d/permanent.local
[DEFAULT]
bantime = -1
maxretry = 2
# Appliquer à chaud sans redémarrage
docker exec -it mailserver fail2ban-client set postfix bantime -1
docker exec -it mailserver fail2ban-client set dovecot bantime -1
docker exec -it mailserver fail2ban-client set custom bantime -1

docker exec -it mailserver fail2ban-client set postfix maxretry 2
docker exec -it mailserver fail2ban-client set dovecot maxretry 2
docker exec -it mailserver fail2ban-client set custom maxretry 2

Vérifier les IPs bannies

docker exec -it mailserver fail2ban-client banned
Paramètre Valeur Description
bantime -1 (permanent) Durée du ban
maxretry 2 Tentatives avant ban
action nftables-allports Bloque tous les ports
blocktype drop Silence total (blackhole)

VPN WireGuard

Le port 993 (IMAP) est restreint à l'interface WireGuard uniquement. Seule la machine maison peut accéder aux mails stockés sur le VPS — depuis internet c'est fermé.

⚠️ Sur un serveur, utilisez AllowedIPs = 10.8.0.0/24 (split tunnel) et non 0.0.0.0/0 pour éviter de perdre l'accès SSH.

Configuration client WireGuard sur homeserver

# /etc/wireguard/wg0.conf
[Interface]
Address = 10.8.0.X/24
PrivateKey = <PRIVATE_KEY>
DNS = <DNS_LOCAL>

[Peer]
PublicKey = <PUBLIC_KEY_HUB>
PresharedKey = <PSK>
AllowedIPs = 10.8.0.0/24   # Split tunnel : uniquement le subnet VPN
PersistentKeepalive = 25    # Important derrière NAT
Endpoint = <IP_HUB>:51820
systemctl enable wg-quick@wg0
systemctl start wg-quick@wg0
ping 10.8.0.X  # IP WireGuard du VPS

Forcer la résolution DNS pour le certificat TLS

# Sur homeserver — mail.example.com doit résoudre vers l'IP WireGuard
echo "10.8.0.X mail.example.com" >> /etc/hosts

ℹ️ Cette entrée /etc/hosts permet à mbsync de se connecter via WireGuard tout en validant le certificat TLS (émis pour mail.example.com).

Synchronisation des mails (mbsync)

mbsync (isync) pull les mails depuis le VPS IONOS vers le stockage local toutes les 5 minutes via le tunnel WireGuard. C'est lui qui vide le cache VPS — avec Expunge Far, les mails sont supprimés du VPS après synchronisation.

Dockerfile mbsync

# /applis/mbsync/Dockerfile
FROM alpine:latest
RUN apk add --no-cache isync ca-certificates
WORKDIR /root
docker build -t mbsync:local /applis/mbsync

Configuration mbsync — compte principal

# /applis/mbsync/config/mail.conf
IMAPAccount principal
Host mail.example.com
Port 993
User user@example.com
PassCmd "cat /config/mail-password"
TLSType IMAPS
CertificateFile /etc/ssl/certs/ca-certificates.crt

IMAPStore principal-remote
Account principal

MaildirStore principal-local
Path /maildir/
Inbox /maildir/INBOX
SubFolders Verbatim

Channel principal
Far :principal-remote:
Near :principal-local:
Patterns INBOX Sent Drafts Junk Trash
Sync Pull
Expunge Far       # Supprime du VPS après pull (comportement cache)
Create Near
SyncState *

Configuration mbsync — Gmail (optionnel)

# /applis/mbsync/config/gmail.conf
IMAPAccount gmail
Host imap.gmail.com
Port 993
User votre@gmail.com
PassCmd "cat /config/gmail-password"
TLSType IMAPS
AuthMechs LOGIN    # Requis pour Gmail avec app password
CertificateFile /etc/ssl/certs/ca-certificates.crt

IMAPStore gmail-remote
Account gmail

MaildirStore gmail-local
Path /maildir/gmail/
Inbox /maildir/gmail/INBOX
SubFolders Verbatim

Channel gmail
Far :gmail-remote:
Near :gmail-local:
Patterns INBOX "[Gmail]/Messages envoyés" "[Gmail]/Corbeille" "[Gmail]/Brouillons" "Courrier indésirable"
Sync Pull
Create Near
SyncState *

ℹ️ Pour Gmail, utilisez un mot de passe d'application (pas votre mot de passe principal). Générez-le depuis : Compte Google → Sécurité → Mots de passe des applications.

Stack docker-compose mbsync (homeserver)

version: "3.8"

services:
  mbsync:
    image: mbsync:local
    container_name: mbsync
    dns:
      - 8.8.8.8
      - 1.1.1.1
    extra_hosts:
      - "mail.example.com:10.8.0.X"  # IP WireGuard du VPS
    command: sh -c "while true; do mbsync -a -c /config/mail.conf; mbsync -a -c /config/gmail.conf; chown -R 5000:5000 /maildir; sleep 300; done"
    volumes:
      - /applis/mbsync/config:/config:ro
      - /applis/Maildir:/maildir
    restart: unless-stopped

⚠️ Le chown -R 5000:5000 est nécessaire car mbsync crée les fichiers en root, mais Dovecot tourne avec l'uid 5000 (vmail). Je l'ai cherché pendant une heure.

Serveur IMAP local (Dovecot)

Dovecot sert le Maildir local en IMAP pour Snappymail et les clients mail (Thunderbird, apps mobiles).

Dockerfile Dovecot

# /applis/dovecot-build/Dockerfile
FROM alpine:3.18
RUN apk add --no-cache dovecot && adduser -D -u 5000 -g 5000 vmail
EXPOSE 143
CMD ["dovecot", "-F"]

Configuration Dovecot

# /applis/dovecot-build/dovecot.conf
protocols = imap
ssl = no
disable_plaintext_auth = no
log_path = /dev/stderr
info_log_path = /dev/stderr

mail_location = maildir:/maildir:LAYOUT=fs:INBOX=/maildir/INBOX

namespace inbox {
  inbox = yes
  separator = /
  subscriptions = yes
  mailbox gmail/INBOX {
    auto = subscribe
  }
}

passdb {
  driver = static
  args = password=VOTRE_MOT_DE_PASSE
}

userdb {
  driver = static
  args = uid=5000 gid=5000 home=/maildir
}
docker build -t dovecot:local /applis/dovecot-build

Stack docker-compose Dovecot (homeserver)

version: "3.8"

services:
  dovecot:
    image: dovecot:local
    container_name: dovecot
    ports:
      - "143:143"
    volumes:
      - /applis/Maildir:/maildir
      - /applis/dovecot-build/dovecot.conf:/etc/dovecot/dovecot.conf
    restart: unless-stopped
    networks:
      - proxy_traefik_web

networks:
  proxy_traefik_web:
    external: true

ℹ️ Le port 143 (IMAP sans TLS) est intentionnel — les connexions restent sur le réseau local uniquement. Pour une exposition externe, utilisez le port 993 avec TLS.

Webmail Snappymail

Stack docker-compose (homeserver)

version: "3.8"

services:
  snappymail:
    image: djmaze/snappymail:latest
    container_name: snappymail
    restart: unless-stopped
    volumes:
      - /applis/snappymail:/var/lib/snappymail
    networks:
      - proxy_traefik_web
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.snappymail.rule=Host(`mail.lan`)"
      - "traefik.http.routers.snappymail.entrypoints=web"
      - "traefik.http.services.snappymail.loadbalancer.server.port=8888"

networks:
  proxy_traefik_web:
    external: true

Configuration du domaine dans Snappymail

Accédez au panel admin : http://mail.lan/?admin

docker exec snappymail cat /var/lib/snappymail/_data_/_default_/admin_password.txt
Paramètre Valeur
IMAP Server dovecot
IMAP Port 143
Security None
SMTP Server À configurer (La Poste ou autre relay)

Intégration Gmail

⚠️ Gmail avec Patterns * télécharge TOUS les libellés, causant des doublons car un mail peut avoir plusieurs libellés. Utilisez des patterns explicites.

Dossiers standards Gmail à synchroniser (sans doublons) :

Patterns INBOX "[Gmail]/Messages envoyés" "[Gmail]/Corbeille" "[Gmail]/Brouillons" "Courrier indésirable"

Lister les dossiers disponibles sur votre compte Gmail :

docker exec mbsync mbsync --list -c /config/gmail.conf gmail

⚠️ Google impose une limite de bande passante IMAP (~2.5 Go/jour). Pour de grandes boîtes, le sync initial peut prendre plusieurs jours. Augmentez l'intervalle à sleep 900 pour réduire la pression.

SPF / DMARC

Ces deux enregistrements DNS sont ce qui sépare "mes mails arrivent en spam" de "mes mails arrivent en boîte de réception". J'aurais dû les configurer en premier.

SPF — déclare les serveurs autorisés à envoyer

@    TXT    "v=spf1 ip4:<IP_VPS> include:<smtp-relay.example.com> ~all"

DMARC — politique en cas d'échec SPF/DKIM

_dmarc    TXT    "v=DMARC1; p=quarantine; rua=mailto:user@example.com"

Vérification

dig TXT example.com +short @8.8.8.8
dig TXT _dmarc.example.com +short @8.8.8.8

Audit de sécurité

Ports exposés depuis internet

Port Service Statut attendu
25 SMTP entrant ✓ Ouvert
80 HTTP (redirect) ✓ Ouvert
443 HTTPS ✓ Ouvert
993 IMAPS ✓ Fermé (WireGuard only)

Vérification TLS SMTP

openssl s_client -connect mail.example.com:25 -starttls smtp 2>/dev/null | grep -E "subject|issuer|verify"

Vérification fail2ban

docker exec -it mailserver fail2ban-client banned
docker exec -it mailserver nft list ruleset | grep -A5 "fail2ban"

Reverse DNS (PTR)

Configurez un enregistrement PTR dans le panel IONOS : <IP_VPS>mail.example.com. C'est ce qui m'a bloqué le plus longtemps — sans ça, Gmail classe tous vos mails en spam.

  • Port 993 WireGuard only — IMAP inaccessible depuis internet
  • fail2ban permanent ban — 2 tentatives = ban définitif via nftables
  • TLS sur toutes les connexions — SMTP STARTTLS, IMAPS
  • ⚠️ Reverse DNS — À configurer dans le panel IONOS pour la délivrabilité
  • ⚠️ UCEPROTECTL2/L3 — Les plages IP IONOS peuvent être blacklistées. Non bloquant pour Gmail/Outlook mais peut affecter certains destinataires
Retour aux articles DevOps