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