Stalwart Mail Server einrichten: der komplette Guide
10. August 2026

Diesen Beitrag habe ich zum Großteil von KI schreiben lassen. Dies ermöglicht es mir, ältere Projekte aufzuarbeiten und zu dokumentieren.
Kurzfassung: Ich habe meinen Mailserver von Netcups Shared Hosting auf einen selbst gehosteten Stalwart auf einem Netcup-VPS umgezogen — Docker, PostgreSQL, alles hinter Traefik, mit vollständigem SPF/DKIM/DMARC/MTA-STS-Satz. Diese Anleitung ist keine Theorie, sondern das Protokoll dieser Migration: jeder Befehl hier läuft so bei mir in Produktion. Inklusive der vier, fünf Stellen, an denen ich mich selbst ausgesperrt oder falsch diagnostiziert habe.
Voraussetzung zum Mitlesen: Grundkenntnisse Linux, Docker Compose und DNS. Kein Vorwissen zu Mailprotokollen nötig — das erkläre ich unterwegs.
Zeitaufwand: Ein konzentrierter Nachmittag für das Setup, plus zwei bis drei Wochen Beobachtungsphase, bevor man die DNS-Policies scharf schaltet.
1. Einleitung: Warum ein eigener Mailserver?
Über die letzten Monate habe ich mich Schritt für Schritt von Cloud-Anbietern verabschiedet — Fotos laufen inzwischen über Immich, VPN über WireGuard, Analytics über Umami. Mail war das letzte große Stück, und gleichzeitig das unangenehmste: Bei einem Fotoserver merkt man einen Ausfall irgendwann. Bei Mail merkt man ihn nie, weil die Nachricht einfach nicht ankommt und niemand Bescheid sagt.
Der Antrieb ist nicht nur digitale Souveränität, sondern auch: volle Kontrolle über Spam-Filterung, Regeln, Aliase und Speicherort — und langfristig eine durchsuchbare Mail-Historie, an die kein Anbieter-Suchindex herankommt.
Warum Stalwart und nicht Mailcow oder Mailu? Drei Gründe, in dieser Reihenfolge:
- Ein Prozess statt eines Zoos. Mailcow orchestriert rund ein Dutzend Container (Postfix, Dovecot, Rspamd, SOGo, ClamAV, Redis, …). Stalwart ist ein einziges Rust-Binary, das SMTP, IMAP, JMAP, CalDAV, CardDAV und WebDAV gleichzeitig spricht.
- Moderne Standards ab Werk. DKIM-Rotation, MTA-STS, DANE, TLS-Reporting und automatische DNS-Pflege sind eingebaut, nicht angeflanscht.
- Rust und ein externes Sicherheitsaudit. Bei einem Dienst, der per Definition ungefilterten Input aus dem Internet parst, ist Speichersicherheit kein Nice-to-have.
Der ehrliche vierte Grund: Ich wollte verstehen, wie Mail funktioniert. Ein Managed-Hoster nimmt einem genau dieses Verständnis ab.
Was am Ende steht: mail.tariwiencke.de als einziger Hostname für Web-UI, JMAP, IMAP, SMTP und DAV. Alle Mailports laufen über Traefik, TLS kommt von Let's Encrypt, DNS-Records pflegt Stalwart selbst über die Netcup-API. Zustellung zu Gmail und Outlook läuft unauffällig, SPF, DKIM und DMARC bestehen die Prüfung beim Empfänger.
Endergebnis: eine Domain, aktiviert, DNS-Verwaltung automatisch, Zertifikatsverwaltung manuell — genau die zwei Entscheidungen, um die es in Kapitel 14 und 15 geht.
2. Was ist Stalwart?
Stalwart ist ein quelloffener All-in-one-Server für Mail und Collaboration, geschrieben in Rust. Ein einzelnes Binary bedient:
| Bereich | Protokolle |
|---|---|
| Mailempfang/-versand | SMTP, SMTPS, Submission |
| Postfachzugriff | IMAP4, POP3, JMAP |
| Kalender & Kontakte | CalDAV, CardDAV, WebDAV |
| Filterverwaltung | ManageSieve |
| Administration | Web-UI, REST/JMAP-API, CLI |
Sicherheitsseitig bringt es mit, was man sonst einzeln zusammenstecken muss: DANE, MTA-STS, TLS-Reporting, granulare ACLs, Rate-Limiting, automatisches IP-Banning, ACME-Client für Let's Encrypt sowie automatische DKIM-Key-Generierung, -Rotation und -DNS-Veröffentlichung.
Beim Speicher ist Stalwart bewusst agnostisch, unterscheidet dabei aber vier getrennte Rollen. Als Data Store stehen RocksDB, FoundationDB, PostgreSQL, MySQL/MariaDB und SQLite zur Wahl. Der Blob Store kann zusätzlich S3-kompatibler Objektspeicher, Azure Blob Storage oder das Dateisystem sein, der In-Memory Store auch Redis, und als Suchindex kommen unter anderem Elasticsearch, Meilisearch oder derselbe SQL-Store in Frage.
Lizenz: Stalwart ist dual lizenziert — die Community Edition unter AGPL-3.0, daneben eine kommerzielle Enterprise Edition. Die Community Edition hat keine Limits bei Postfächern oder Domains; Enterprise ergänzt vor allem Multi-Tenancy und Betriebswerkzeuge für größere Installationen. Alles in diesem Artikel läuft auf der Community Edition.
Wichtiger Reifegrad-Hinweis: Stalwart ist zum Zeitpunkt dieses Artikels bei v0.16.x und damit formal vor 1.0. Das merkt man an gelegentlichen Breaking Changes zwischen Minor-Versionen — deshalb pinne ich unten bewusst auf v0.16 statt :latest. Wer produktive Fremdpostfächer betreibt, sollte das einkalkulieren.
3. Für wen eignet sich Stalwart?
Gut geeignet:
- Entwickler und Self-Hosting-Enthusiasten, die einen einzigen, verständlichen Dienst einem Container-Zoo vorziehen.
- Kleine Teams und Vereine — über LDAP-, SQL- und OIDC-Anbindung lässt sich eine bestehende Nutzerverwaltung einbinden, statt Konten doppelt zu pflegen.
- Setups, die Mail und Kalender/Kontakte aus einer Hand wollen. Das ist Stalwarts eigentliches Alleinstellungsmerkmal gegenüber reinen Mailservern.
Eher nicht geeignet:
- Wer Mail betreiben will, ohne DNS zu verstehen. Ein Mailserver ist zu 30 % Software und zu 70 % korrekte DNS-Records.
- Wer keinen Backup- und Restore-Plan hat, bevor die erste echte Mail ankommt. Dazu Kapitel 24.
- Wer eine grafische Weboberfläche für Endnutzer erwartet: Das mitgelieferte Web-Interface ist eine Administrations- und Self-Service-Oberfläche, kein Webmail-Client. Zum Mailen braucht es einen IMAP- oder JMAP-Client.
4. Architektur des Setups
Internet
│
┌──────────────┼───────────────────────────┐
│ 25 / 465 / 993 80 / 443 │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ Traefik v3.6 172.20.0.2 (traefik-net) │
│ • TCP-Passthrough + PROXY Protocol v2 (Mailports) │
│ • TLS-Terminierung + ACME (HTTP-01) für HTTPS │
│ • traefik-certs-dumper → PEM ins Volume │
└─────────────────────────────────────────────────────┘
│ Ports 25/465/993 roh │ HTTP → 8080
▼ ▼
┌─────────────────────────────────────────────────────┐
│ Stalwart v0.16 172.20.0.4 (traefik-net) │
│ Listener: smtp:25 submissions:465 imaps:993 │
│ http:8080 (Web-UI / JMAP / DAV) │
└─────────────────────────────────────────────────────┘
│ stalwart-net (intern)
▼
┌────────────────────┐
│ PostgreSQL 17 │ Data + Blob + FTS + InMemory
└────────────────────┘
Drei Entscheidungen prägen dieses Bild, und alle drei haben mich Zeit gekostet:
Alles läuft über Traefik — auch die Mailports
Mein erster Ansatz war, die Mailports direkt vom Container auf den Host zu publishen und Traefik nur HTTP(S) machen zu lassen. Die Begründung klang plausibel: Traefik routet nach SNI, und STARTTLS-Protokolle beginnen im Klartext — da steht am Verbindungsanfang kein TLS-Handshake, den man auslesen könnte.
Das war technisch korrekt, aber unvollständig. TCP-Passthrough reicht rohe Bytes durch, ganz ohne SNI-Interpretation, und ist genau das Muster, das Stalwarts offizieller Traefik-Guide beschreibt.
Der eigentliche Grund für den Umbau war aber ein anderer: Der offizielle Guide kombiniert Passthrough mit PROXY Protocol v2. Traefik hängt beim Verbindungsaufbau ein Header-Präfix an, das die echte Client-IP transportiert. Ohne das sieht Stalwart bei jeder eingehenden Mail nur Traefiks Container-IP — und dann laufen SPF-Prüfung, Rate-Limiting und Auto-Banning gegen die falsche Adresse ins Leere.
Feste Container-IPs
PROXY Protocol braucht eine stabile Absenderadresse, gegen die Stalwart prüfen kann. Deshalb bekommen Traefik (172.20.0.2) und Stalwart (172.20.0.4) feste IPs im gemeinsamen traefik-net. Das Subnetz habe ich nicht geraten, sondern nachgesehen:
docker network inspect traefik-net --format '{{json .IPAM.Config}}'
PostgreSQL statt SQLite
Für einen Mailserver dieser Größe wäre SQLite völlig ausreichend — Stalwarts eigene Empfehlung für kleine Installationen. Ich habe trotzdem Postgres genommen, mit Blick nach vorn: Perspektivisch soll die Mail-Historie per Embedding-basierter, semantischer Suche durchsuchbar sein, und pgvector ist dafür der naheliegende Baustein. Den Store später zu migrieren wäre deutlich unangenehmer, als ihn jetzt schon richtig zu setzen.
Postgres läuft als eigener Container pro Dienst, nicht als geteilte Instanz. Weniger Kopplung, und jeder Dienst lässt sich unabhängig sichern und migrieren.
5. Voraussetzungen
| Anforderung | Mein Setup | Minimum laut Doku |
|---|---|---|
| Server | Netcup VPS, Debian 13 | 1 CPU-Kern, 1 GB RAM für 5–10 Nutzer |
| IP | Statische IPv4, öffentlich | zwingend |
| Reverse DNS (PTR) | auf mail.tariwiencke.de gesetzt | zwingend für Zustellbarkeit |
| Domain | eigene Domain mit DNS-Zugriff | zwingend |
| Ports | 25, 465, 993, 80, 443 offen | 80 nur für ACME HTTP-01 |
| Docker | aktuelle Engine + Compose-Plugin | — |
Der Punkt, an dem die meisten Setups scheitern, ist nicht die Software, sondern Port 25. Viele Anbieter sperren ausgehendes SMTP standardmäßig und geben es erst auf Anfrage frei. Vor allen anderen Schritten prüfen:
nc -zv -w5 gmail-smtp-in.l.google.com 25
Kommt hier ein Timeout statt einer Verbindung, ist alles Weitere sinnlos, bis der Anbieter Port 25 freischaltet.
Ebenso wichtig: der PTR-Record. Er wird nicht in der DNS-Zone gesetzt, sondern beim Anbieter der IP-Adresse — bei Netcup im Customer Control Panel unter Produktverwaltung, nicht im DNS-Bereich. Er muss exakt auf den Hostnamen zeigen, mit dem sich der Server beim SMTP-Handshake vorstellt:
dig -x 159.195.46.154 +short # muss mail.tariwiencke.de. liefern
Ein HELO/PTR-Mismatch ist eines der zuverlässigsten Signale, mit denen Empfängerserver Mail in den Spam-Ordner sortieren.
6. Server vorbereiten
Diese Anleitung setzt einen bereits gehärteten Server voraus. Die Kurzliste dessen, was vorher stehen muss:
- Aktuelles System:
apt update && apt full-upgrade, unattended-upgrades aktiv. - SSH abgesichert: Public-Key-Auth,
PermitRootLogin prohibit-password, Passwort-Login aus. - Firewall: Default-Deny eingehend, freigegeben nur 22, 80, 443, 25, 465, 993 (plus was andere Dienste brauchen).
Zur Firewall eine ehrliche Anmerkung: Bei mir stand zum Zeitpunkt der Migration kein dokumentiertes Host-Firewall-Regelwerk, und ein anderer Dienst auf demselben Server exponierte seinen Postgres-Port direkt nach außen. Das ist genau die Art von Altlast, die man beim Aufsetzen eines Mailservers findet und dann verschiebt. Nicht nachmachen — ufw einrichten, bevor der Mailserver live geht.
Auch relevant: Docker umgeht ufw-Regeln, weil es seine Regeln direkt in iptables einträgt. Wer mit ufw arbeitet, sollte Container-Ports gar nicht erst auf 0.0.0.0 publishen, sondern — wie unten — ausschließlich über den Reverse Proxy erreichbar machen.
7. Docker installieren
curl -fsSL https://get.docker.com | sh
docker --version
docker compose version
Zwei Dinge, die später Zeit sparen:
- Compose-Plugin, nicht
docker-compose. Alle Befehle unten nutzendocker compose(V2, Leerzeichen). - Ein eigenes, externes Netzwerk für alles, was der Reverse Proxy erreichen soll. Das legt man einmal an, und alle Compose-Projekte referenzieren es dann per
external: true:
docker network create traefik-net
8. Docker Compose erstellen
Projektstruktur
Ich verwalte den kompletten Server als Git-Repository, in dem jeder Ordner 1:1 einem Verzeichnis unter /opt auf dem VPS entspricht:
/opt/
├── traefik/
│ ├── docker-compose.yml
│ ├── .env
│ └── acme.json
└── stalwart/
├── docker-compose.yml
├── .env
├── config/ # → /etc/stalwart (config.json)
├── data/ # → /var/lib/stalwart
├── postgres/ # Postgres-Datenverzeichnis
├── backups/ # pg_dump-Ziel
└── archives/ # Vandelay-Migrationsarchive
config/ und data/ sind bewusst Bind-Mounts, keine Named Volumes: Restic sichert später direkt vom Host, und dafür müssen die Daten dort sichtbar liegen.
Compose-Datei
services:
stalwart:
# Für Produktivbetrieb auf v<major>.<minor> pinnen statt :latest —
# vermeidet Breaking Changes bei einem beiläufigen Redeploy.
image: stalwartlabs/stalwart:v0.16
container_name: stalwart_app
restart: always
env_file:
- .env
# Kein ports:-Block. Jeglicher Traffic — Web-UI, JMAP und alle
# Mailprotokolle — kommt ausschließlich über Traefik herein.
volumes:
- ./config:/etc/stalwart
- ./data:/var/lib/stalwart
# Von traefik-certs-dumper befülltes Volume: Stalwart liest das
# Let's-Encrypt-Zertifikat für die TLS-Passthrough-Ports direkt
# von der Platte.
- certs:/data/certs:ro
depends_on:
db:
condition: service_healthy
networks:
traefik-net:
ipv4_address: 172.20.0.4
stalwart-net:
labels:
- 'traefik.enable=true'
# ... Router-Labels siehe Kapitel 9
- 'traefik.docker.network=traefik-net'
db:
image: postgres:17-alpine
container_name: stalwart_postgres
restart: always
env_file:
- .env
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 5s
timeout: 5s
retries: 5
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
- stalwart-net
networks:
traefik-net:
external: true
stalwart-net:
driver: bridge
volumes:
# `external: true`, weil das Volume von traefik/ angelegt wird (Kapitel 15)
# und hier nur referenziert. Deshalb muss Traefik zuerst starten.
certs:
name: traefik_certs
external: true
Erklärung der wichtigsten Punkte
Kein eigener healthcheck für Stalwart. Das offizielle Image bringt laut seinem Dockerfile bereits einen mit (curl gegen /healthz/live, mit Fallback von HTTPS:443 auf HTTP:8080). Ich hätte beinahe einen zweiten geschrieben — und dabei denselben Fehler wiederholt wie bei einem anderen Dienst in diesem Repo, wo ich wget genutzt hatte, das im Image gar nicht existierte. Regel: erst ins Dockerfile schauen, ob schon einer da ist und welches Tool überhaupt im Container liegt.
Zwei Netzwerke. traefik-net ist die öffentliche Kante, stalwart-net verbindet Stalwart mit seiner Datenbank. Postgres hängt bewusst nur im internen Netz und ist von außen unerreichbar.
depends_on mit condition: service_healthy. Ohne das startet Stalwart schneller als Postgres bereit ist und stirbt an einem Verbindungsfehler.
Die .env
Ich lege sie als .env.example mit Platzhaltern ins Repo und kopiere sie auf dem Server zur echten .env — so ist dokumentiert, welche Variablen es gibt, ohne dass je ein echter Wert in Git landet:
# Postgres
POSTGRES_USER=stalwart
POSTGRES_PASSWORD=XXXXXXXX
POSTGRES_DB=stalwart
# Bootstrap-Admin für den allerersten Start (Format: username:password).
# Ohne diese Variable erzeugt Stalwart ein Einmal-Passwort und zeigt es
# NUR EINMAL im Log — pinnen ist vorhersagbarer.
# WICHTIG: nach der Ersteinrichtung wieder entfernen, siehe Kapitel 26.
STALWART_RECOVERY_ADMIN=admin:XXXXXXXX
# Öffentliche URL, unter der Clients Web-UI/JMAP erreichen.
STALWART_PUBLIC_URL=https://mail.tariwiencke.de
# Nur nötig, wenn der HTTP-Listener auf 8080 selbst fehlt oder gelöscht
# wurde — reaktiviert ihn unabhängig vom Directory-Zustand. Bei einer
# normalen Aussperrung genügt STALWART_RECOVERY_ADMIN, siehe Kapitel 26.
# STALWART_RECOVERY_MODE=1
# API-Passwort des DNS-Providers für die automatische DNS-Verwaltung.
NETCUP_API_PASSWORD=XXXXXXXX
Fallstrick, der eine halbe Stunde gekostet hat: Kein $ in generierten Passwörtern verwenden. Docker Compose interpoliert $ in .env-Werten selbst — aus $Vx8k… wird eine undefinierte Variable und damit ein leerer Passwortteil.
9. Traefik konfigurieren
Traefik macht hier zwei völlig verschiedene Jobs gleichzeitig, und das Verständnis dieser Trennung ist der Kern des ganzen Setups.
Entrypoints
services:
traefik:
image: traefik:v3.6
container_name: traefik
command:
- "--api.dashboard=true"
- "--api.insecure=false"
- "--providers.docker=true"
- "--providers.docker.network=traefik-net"
# Jeder Container ohne explizites traefik.enable=true bekäme sonst
# automatisch einen Router — inklusive aller Postgres-Sidecars.
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0"
- "--certificatesresolvers.myresolver.acme.email=mail@tariwiencke.de"
- "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.myresolver.acme.storage=acme.json"
# Mail-Entrypoints
- "--entrypoints.smtp.address=:25"
- "--entrypoints.smtps.address=:465"
- "--entrypoints.imaps.address=:993"
- "--log.level=INFO"
ports:
- "80:80"
- "443:443"
- "25:25"
- "465:465"
- "993:993"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/traefik/acme.json:/acme.json
networks:
traefik-net:
ipv4_address: 172.20.0.2
networks:
# Einmalig angelegt mit: docker network create traefik-net
traefik-net:
external: true
volumes:
# Wird in Kapitel 15 von traefik-certs-dumper befüllt und von Stalwart
# gelesen. Name explizit gesetzt, damit das andere Compose-Projekt ihn
# per `external: true` referenzieren kann.
certs:
name: traefik_certs
respondingTimeouts.readTimeout=0 steht bewusst auf dem HTTPS-Entrypoint, nicht auf den Mailports: Es hebt das Lese-Timeout für langlebige HTTP-Verbindungen auf — gedacht für lang offene Requests wie JMAP-Push. Traefik dokumentiert den Wert als Timeout fürs Lesen des Requests; dass genau das hier der Auslöser war, ist meine Herleitung, kein Beleg. Die Mailports laufen als TCP-Router und sind davon nicht betroffen.
exposedbydefault=false ist ein Sicherheitsfix, kein Aufräumen. Traefiks Default ist true — jeder Container auf dem Host bekommt dann automatisch einen Router. Bei mir betraf das vier Postgres-Container, von denen ich drei gar nicht auf dem Schirm hatte. Aufgefallen ist es nur, weil einer davon nicht im selben Netzwerk lag und eine Warnung ins Log schrieb.
Router-Labels für Stalwart
Der HTTP-Teil ist unspektakulär — Traefik terminiert TLS und reicht Klartext-HTTP an Port 8080 weiter:
- traefik.http.routers.mailserver.rule=Host(`mail.tariwiencke.de`) || Host(`autodiscover.tariwiencke.de`) || Host(`autoconfig.tariwiencke.de`) || Host(`mta-sts.tariwiencke.de`) || Host(`imap.tariwiencke.de`) || Host(`smtp.tariwiencke.de`)
- 'traefik.http.routers.mailserver.entrypoints=websecure'
- 'traefik.http.routers.mailserver.tls=true'
- 'traefik.http.routers.mailserver.tls.certresolver=myresolver'
- 'traefik.http.routers.mailserver.service=stalwart'
- 'traefik.http.services.stalwart.loadbalancer.server.port=8080'
Alle sechs Hostnamen hängen bewusst an einem Router: Traefik holt pro Router ein kombiniertes SAN-Zertifikat für alle darin gelisteten Host()-Namen. Der erste Name in der Liste ist die „Main Domain“ dieses Zertifikats — das wird in Kapitel 15 wichtig.
Der TCP-Teil ist der interessante:
# SMTP (25) — STARTTLS: die Verbindung beginnt im Klartext,
# der TLS-Handshake steht NICHT am Anfang. Deshalb KEIN
# tls.passthrough — Traefik reicht rohe Bytes durch.
- 'traefik.tcp.routers.stalwart-smtp.rule=HostSNI(`*`)'
- 'traefik.tcp.routers.stalwart-smtp.entrypoints=smtp'
- 'traefik.tcp.routers.stalwart-smtp.service=stalwart-smtp'
- 'traefik.tcp.services.stalwart-smtp.loadbalancer.server.port=25'
- 'traefik.tcp.services.stalwart-smtp.loadbalancer.proxyProtocol.version=2'
# SMTPS (465) — implizites TLS ab dem ersten Byte. Traefik liest
# nur die SNI fürs Routing und reicht die TLS-Session unangetastet
# durch; Stalwart terminiert TLS selbst.
- 'traefik.tcp.routers.stalwart-smtps.rule=HostSNI(`*`)'
- 'traefik.tcp.routers.stalwart-smtps.tls.passthrough=true'
- 'traefik.tcp.routers.stalwart-smtps.entrypoints=smtps'
- 'traefik.tcp.routers.stalwart-smtps.service=stalwart-smtps'
- 'traefik.tcp.services.stalwart-smtps.loadbalancer.server.port=465'
- 'traefik.tcp.services.stalwart-smtps.loadbalancer.proxyProtocol.version=2'
# IMAPS (993) — wie SMTPS
- 'traefik.tcp.routers.stalwart-imaps.rule=HostSNI(`*`)'
- 'traefik.tcp.routers.stalwart-imaps.tls.passthrough=true'
- 'traefik.tcp.routers.stalwart-imaps.entrypoints=imaps'
- 'traefik.tcp.routers.stalwart-imaps.service=stalwart-imaps'
- 'traefik.tcp.services.stalwart-imaps.loadbalancer.server.port=993'
- 'traefik.tcp.services.stalwart-imaps.loadbalancer.proxyProtocol.version=2'
Der eine Unterschied, der zählt: tls.passthrough=true steht nur bei 465 und 993. Bei Port 25 mit STARTTLS gibt es am Verbindungsanfang keinen TLS-Handshake und damit keine SNI — Traefik kann dort nichts durchreichen, was noch nicht existiert. Setzt man es trotzdem, bleiben eingehende Mails schlicht hängen.
HostSNI(*) heißt: der Hostname ist egal. Die TCP-Router greifen für jede Verbindung auf dem jeweiligen Entrypoint. Das erklärt eine spätere Beobachtung: imap.tariwiencke.de:993 erreicht Stalwart auch dann, wenn dieser Name nirgends in einer Router-Regel steht — nur das ausgelieferte Zertifikat passt dann nicht (siehe Kapitel 21).
Was ich bewusst nicht übernommen habe
Zwei Dinge aus dem offiziellen Guide sind hier nicht umgesetzt, beides absichtlich:
- Ein zweiter TCP-Passthrough-Router auf Stalwarts eigenen Port 443. Der Guide bietet HTTP-Upstream und TCP-Passthrough als gleichwertige Alternativen an, nicht als Ergänzung. Der HTTP-Upstream auf 8080 deckt Web-UI und JMAP vollständig ab; ein zusätzlicher Passthrough-Router hätte nur SNI-Overlap und Prioritätsfragen auf Port 443 gebracht.
proxyProtocol.trustedIPsauf den Mail-Entrypoints. Diese Einstellung betrifft die Richtung zu Traefik hin — sie ist nur nötig, wenn vor Traefik noch ein weiterer Proxy steht, der selbst PROXY-Header schickt. Auf diesem VPS ist Traefik die öffentliche Kante. Wirksam ist die andere Richtung: das service-seitigeproxyProtocol.version=2plus StalwartsoverrideProxyTrustedNetworks(Kapitel 26).
10. Stalwart starten
Reihenfolge beachten: Zuerst Traefik, weil dessen Compose-Datei das traefik_certs-Volume anlegt, das Stalwart per external: true referenziert. Umgekehrt scheitert der Start mit volume ... not found.
cd /opt/traefik && docker compose up -d
Dann Stalwart:
cd /opt/stalwart
cp .env.example .env
# POSTGRES_PASSWORD und STALWART_RECOVERY_ADMIN eintragen (kein "$"!)
mkdir -p config data
chown -R 2000:2000 config data
docker compose up -d
chown -R 2000:2000 ist nicht optional. Das Image läuft als unprivilegierter Nutzer mit UID/GID 2000. Bei Bind-Mounts legt Docker neue Host-Verzeichnisse als root an — der Container kann dort dann nichts schreiben und stirbt mit Permission denied (os error 13). Named Volumes hätten das Problem nicht, kollidieren aber mit der Backup-Strategie.
Logs prüfen
docker compose ps
docker compose logs -f stalwart
docker compose ps steht hier zuerst, und zwar aus einem konkreten Grund. Nach meinem ersten Setup-Durchlauf lieferte Traefik „Bad Gateway“. Ich habe daraufhin zwei Stunden über Portkonfiguration theoretisiert und zwei falsche Hypothesen aufgestellt, bevor ich den simpelsten Diagnosebefehl ausgeführt habe. Der Container lief schlicht nicht.
Lehre, teuer bezahlt: Bei einem konkreten Fehlersymptom zuerst den direkten Diagnosebefehl ausführen, statt vom Symptom auf eine plausibel klingende Ursache zurückzuschließen. Und bei Architekturfragen die Rohdokumentation lesen statt einer Zusammenfassung — beide Fehlhypothesen kamen aus zusammengefassten Doku-Seiten, die die Originaldoku so nie hergab.
11. Erste Anmeldung und Setup-Wizard
Beim allerersten Start ohne config.json läuft Stalwart in den Bootstrap-Modus: Der Wizard ist über Traefik erreichbar, der TLS terminiert und intern an Port 8080 weiterreicht. Login mit den Zugangsdaten aus STALWART_RECOVERY_ADMIN.
⚠️ Unter welchem Hostnamen — das ist der Stolperstein, wenn du migrierst.
Wenn
mail.<domain>noch auf den alten Mailserver zeigt, kannst du den Wizard dort nicht aufrufen. Und Traefik kann für diesen Namen auch kein Zertifikat holen: Let's Encrypt validiert per HTTP-01 gegen die IP, auf die der Name aktuell zeigt — und das ist der alte Hoster.Deshalb für die gesamte Aufbau- und Testphase einen neuen, bisher ungenutzten Namen verwenden, etwa
stalwart.<domain>. Der zeigt sofort auf die VPS, ohne irgendetwas Bestehendes zu berühren, und Traefik bekommt dafür problemlos ein Zertifikat. In der Router-Regel aus Kapitel 9 steht dann zunächst dieser Name, undSTALWART_PUBLIC_URLebenso.Beim Cutover (Kapitel 14) wird er durch
mail.<domain>ersetzt. Zwei Dinge dabei beachten:DOMAINim certs-dumper muss dem erstenHost()-Namen der Router-Regel folgen (Kapitel 15), undSTALWART_PUBLIC_URLmuss mitgezogen werden.Wer keinen alten Mailserver ablöst, sondern auf einer frischen Domain startet, kann diesen Umweg überspringen und direkt
mail.<domain>nehmen.
Fünf Schritte, hier mit Begründung statt nur mit Klickpfad.
Schritt 1 — Server Identity

Schritt 1. Entscheidend sind die beiden Schalter unten: TLS aus, weil Traefik das übernimmt — Signing Keys an, weil daraus die DKIM-Schlüssel entstehen.
| Feld | Wert | Warum |
|---|---|---|
| Server Hostname | mail.tariwiencke.de | Der MTA-Hostname. Muss zu PTR und HELO passen. |
| Default Email Domain | tariwiencke.de | Domain der Postfächer — bewusst etwas anderes als der Server-Hostname. |
| Automatically Obtain TLS Certificate | Aus | Traefik bleibt die einzige ACME-Instanz auf dem Host. |
| Generate Email Signing Keys | An | Erzeugt die DKIM-Keys. Der Public Key wird später veröffentlicht. |
Warum kein eigenes ACME in Stalwart? Stalwart kann Zertifikate selbst holen, aber HTTP-01 und TLS-ALPN-01 brauchen exklusiven Zugriff auf Port 80 bzw. 443 — die hat schon Traefik. Bleibt: Traefik holt, Stalwart liest mit (Kapitel 15).
Meine zweite Begründung war falsch, und das ist lehrreich: Ich hatte DNS-01 zusätzlich mit „mein DNS-Anbieter hat keine passende API“ abgetan. Das stimmte nicht — derselbe Anbieter wird von Stalwart als DNS-Provider unterstützt, und genau diese Integration nutze ich inzwischen für die automatische DNS-Verwaltung (Kapitel 14). DNS-01 wäre also möglich gewesen und braucht überhaupt keine freien Ports. Warum ich trotzdem bei Traefik geblieben bin, steht in Kapitel 15 — dort hat diese Entscheidung nämlich einen Preis.
Schritt 2 — Storage

Schritt 2. „Secret read from environment variable“ statt Klartext — das ist der Grund, warum die erzeugte config.json gefahrlos ins Repo darf.
Alle vier Rollen (Data, Blob, Full-Text-Search, In-Memory) zeigen auf denselben Postgres-Container. Verbindungsdaten: Host db, Port 5432, Database stalwart, User stalwart, Timeout 15 s, Max Connections 10, Fast Recycling. TLS aus — die Verbindung verlässt stalwart-net nie.
Beim Passwort „Secret read from environment variable“ wählen und POSTGRES_PASSWORD als Variablennamen eintragen, nicht den Klartextwert. Die resultierende config/config.json enthält dann keinen einzigen Geheimniswert:
{"@type":"PostgreSql","host":"db","port":5432,"database":"stalwart",
"authUsername":"stalwart",
"authSecret":{"@type":"EnvironmentVariable","variableName":"POSTGRES_PASSWORD"},
"poolMaxConnections":10,"timeout":15000,"useTls":false}
Das ist der Grund, warum diese Datei bedenkenlos ins Git-Repo darf.
Schritt 3 — Account Directory

Use the internal directory. Alternativen wären LDAP oder OIDC/SSO — sinnvoll, wenn eine Nutzerverwaltung schon existiert. Für ein Ein-Personen-Setup ist intern die einzig vernünftige Wahl: keine zusätzliche Abhängigkeit, alles über das Web-UI verwaltbar.
Schritt 4 — Logging

Schritt 4. „Console“ statt „Log file“ — die Fehlentscheidung, die weiter unten beschrieben ist, fällt genau hier.
Console, nicht Log file. Ich hatte im ersten Durchlauf „Log file“ gewählt und mir damit selbst ins Knie geschossen: Eine Datei im Container liegt auf einem flüchtigen Dateisystem und ist nach jedem Neustart weg — und docker compose logs stalwart zeigt dann schlicht nichts, weil nichts nach stdout geht. Stalwarts eigener Hilfetext im Wizard sagt das übrigens genau so; ich hatte ihn überlesen.
Rest: Prefix stalwart, ANSI-Farben an, Single-line, Level Info.
Falls schon auf Log file konfiguriert: Management → Settings → Tracing/Logging, umstellen, Container neu starten.
Schritt 5 — Automatic DNS Management

Hier habe ich bewusst zunächst Manual gewählt — und das ist die wichtigste strategische Entscheidung des gesamten Setups.
Koexistenz-Strategie: Solange die alte Mailkonfiguration noch produktiv in derselben DNS-Zone steht, würde eine automatische Synchronisation bestehende Records ungefragt überschreiben. Genau das darf nicht passieren, bevor der neue Server getestet ist. Automatik kommt erst nach dem Cutover (Kapitel 14).
Nach dem Wizard

Die Zusammenfassung am Ende. Ab hier schreibt Stalwart die config.json — der Wizard ist danach nicht mehr erreichbar.
Das Admin-Konto wird als admin@tariwiencke.de angelegt (die Default-Domain wird angehängt) mit einem servergenerierten Einmal-Passwort. Notieren, sofort ändern.
Container neu starten, sonst wird die geschriebene config.json nicht geladen und die eigentlichen Maildienste starten gar nicht:
docker compose restart stalwart
12. Domain hinzufügen
Die Default-Domain aus dem Wizard existiert bereits. Unter Management → Directory → Domains lassen sich die Einstellungen verfeinern. Mein Stand:
| Feld | Wert | Bedeutung |
|---|---|---|
name | tariwiencke.de | — |
catchAllAddress | mail@tariwiencke.de | Fängt Mail an nicht existierende lokale Teile auf |
subAddressing | Enabled | name+shop@domain landet in name@domain |
allowRelaying | false | Kein offenes Relay — unbedingt so lassen |
reportAddressUri | mailto:postmaster | Empfänger für DMARC/TLS-Reports |
dkimManagement | Automatic | siehe Kapitel 17 |
dnsManagement | heute Automatic, in der Aufbauphase bewusst Manual | siehe Kapitel 14 |
Zu catchAllAddress eine Warnung: Ein Catch-All ist bequem und ein Spam-Magnet. Sobald eine Adresse dieser Domain in irgendeiner geleakten Datenbank auftaucht, probieren Spammer systematisch Varianten durch — und alle kommen an. Mit subAddressing als Alternative (name+dienst@domain) bekommt man denselben Nutzen, ohne jede erfundene Adresse anzunehmen. Ich habe Catch-All aus Migrationsgründen an, würde es für ein Neu-Setup aber auslassen.
13. Benutzer anlegen
Management → Directory → Accounts → Create.
Konten sind in v0.16 entweder User (ein echtes Postfach mit Zugangsdaten) oder Group (ein Verteiler, der an alle Mitglieder zustellt). Daneben gibt es Resource und Location für Ressourcenkalender.
Mailinglisten sind seit v0.16 kein Kontotyp mehr, sondern ein eigener Objekttyp mit eigener Konfiguration — ebenso wie Maildomains, API-Keys und OAuth-Clients. Wer ältere Anleitungen liest, findet die noch als Principals beschrieben.
Mein Stand nach der Migration: ein Admin-Konto, ein mail@-Konto (das migrierte Hauptpostfach), ein account@-Konto sowie ein newsletter@-Konto — Letzteres nur, um Newsletter serverseitig sauber vom Rest zu trennen.
Aliase werden pro Konto gepflegt (Feld aliases), nicht als eigene Objekte. Rollen steuern Berechtigungen; die Rolle „System Administrator“ hat vollen Zugriff, normale Konten erben das Nutzer-Set.
Passwörter: Stalwart speichert Hashes und filtert sie aus allen Exporten heraus. Das klingt selbstverständlich, hat aber eine Konsequenz für Backups, die viele überrascht — dazu Kapitel 24.
14. DNS konfigurieren
Hier entscheidet sich, ob der Server funktioniert. Software kann man neu starten; ein falscher MX-Record kostet Mail.
Der vollständige Record-Satz
| Record | Zweck | Mein Wert |
|---|---|---|
| A | wohin der Mail-Hostname zeigt | mail.tariwiencke.de → 159.195.46.154 |
| MX | welcher Host eingehende Mail annimmt | 10 mail.tariwiencke.de. |
| PTR | Reverse-DNS der Server-IP | mail.tariwiencke.de. (beim IP-Anbieter, nicht im DNS) |
| SPF (TXT) | welche Server im Namen der Domain senden dürfen | v=spf1 mx -all |
| DKIM (TXT) | Public Keys zur Signaturprüfung | zwei Selektoren, siehe Kapitel 17 |
DMARC (TXT an _dmarc) | Policy bei SPF/DKIM-Fehlschlag | siehe Kapitel 18 |
MTA-STS (TXT an _mta-sts + Subdomain) | erzwingt TLS für eingehendes SMTP | siehe Kapitel 19 |
TLS-RPT (TXT an _smtp._tls) | Reports über gescheiterte TLS-Verhandlungen | siehe Kapitel 19 |
| SRV | alternative Client-Autodiscovery | automatisch gepflegt |
| Autoconfig/Autodiscover | Client-Ersteinrichtung | siehe Kapitel 21 |
| CAA | welche CA Zertifikate ausstellen darf | 0 issue "letsencrypt.org" |
| TLSA/DANE | bindet das Zertifikat an DNS | nicht gesetzt, siehe Kapitel 20 |
Ausgangslage nicht annehmen, sondern messen. Vor der Migration habe ich den Ist-Zustand per dig erfasst statt aus dem Control Panel abgelesen:
dig MX tariwiencke.de +short
dig TXT tariwiencke.de +short
dig A mail.tariwiencke.de +short
dig -x 159.195.46.154 +short
Dabei kam eine nützliche Überraschung heraus: mail.tariwiencke.de war bereits der bevorzugte MX-Eintrag. Der Hostname-Teil der Migration war also schon entschieden — es musste nur der A-Record dieses Namens umziehen, nicht der MX-Record selbst.
Der Cutover
Bis hierhin hat nichts am produktiven Mailfluss gerührt. Ab jetzt schon.
1. A-Record umbiegen: mail.tariwiencke.de von der alten Hoster-IP auf die VPS-IP. Der MX-Record bleibt unverändert — nur wohin sein Ziel zeigt, ändert sich.
2. SPF anpassen: Der alte Wert v=spf1 mx a include:_spf.webhosting.systems ~all autorisierte die Relay-Server des alten Hosters. Nach dem Cutover ist Stalwart der einzige Absender. Geplanter Wert war v=spf1 mx ~all — bewusst zunächst Soft-Fail. Tatsächlich veröffentlicht die automatische DNS-Verwaltung aber -all, siehe Kapitel 16.
3. DKIM und DMARC veröffentlichen (Kapitel 17 und 18).
4. Verifizieren:
dig +short mail.tariwiencke.de A # muss die VPS-IP liefern
openssl s_client -connect mail.tariwiencke.de:993 \
-servername mail.tariwiencke.de </dev/null
Automatische DNS-Verwaltung aktivieren
Nach dem Cutover entfällt der Grund für den Manuell-Modus. Stalwart kann den kompletten Record-Satz über die Provider-API pflegen — bei rund 70 unterstützten Providern, Netcup eingeschlossen.
DnsServer-Objekt anlegen (Settings → Network → DNS → DNS Providers → Create): Typ, Kundennummer, API-Key eintragen. Timeout, TTL, Polling-Intervall und Propagation-Timeout auf den Defaults lassen (30 s / 5 min / 15 s / 1 min).
Das API-Passwort nicht im Klartext eintippen. Das Feld ist vom Typ SecretKey und unterstützt drei Varianten: direkter Wert, Umgebungsvariable oder Datei. Im Formular über dem Secret-Feld auf „Environment Variable“ umstellen und NETCUP_API_PASSWORD eintragen. Falls das Objekt schon mit Klartext existiert, nachträglich per CLI:
stalwart-cli update DnsServer <id> --json '{
"password": {"@type": "EnvironmentVariable", "variableName": "NETCUP_API_PASSWORD"}
}'
Danach auf der Domain aktivieren — das DnsServer-Objekt definiert nur den Zugang, Domain.dnsManagement schaltet die Nutzung für diese Domain frei:
stalwart-cli update Domain <domain-id> --json '{
"dnsManagement": {"@type": "Automatic", "dnsServerId": "<dns-server-id>"}
}'
Die Default-publishRecords-Liste deckt alles ab: dkim, spf, mx, dmarc, srv, mtaSts, tlsRpt, caa, autoConfig, autoConfigLegacy, autoDiscover. Kein Neustart nötig, nach ein bis zwei Minuten per dig prüfbar.
Zwei Nebenwirkungen, die man kennen sollte
Backup-MX verschwindet. Der Backup-MX des alten Hosters (Priorität 50) ist Stalwart unbekannt und wird von der automatischen MX-Verwaltung entfernt. Für ein Einzelserver-Setup akzeptabel, aber bewusst akzeptieren: Fällt der Server aus, gibt es kein zweites Ziel mehr, das Mail zwischenpuffert. Sendende Server halten dann typischerweise mehrere Tage in ihrer Queue — Mail geht also nicht verloren, kommt aber verspätet.
Die Automatik gleicht nicht laufend ab. Das hat mich zwei Wochen unbemerkt kaputte Zustellung gekostet, siehe Kapitel 27.
15. TLS einrichten
Traefik ist die einzige ACME-Instanz auf dem Host. Für die HTTP-Seite reicht das — für die TLS-Passthrough-Ports 465 und 993 nicht, denn dort terminiert Stalwart selbst und braucht das Zertifikat als Datei.
traefik-certs-dumper
Ein Sidecar exportiert das Zertifikat aus Traefiks acme.json als PEM-Dateien in ein geteiltes Volume:
traefik-certs-dumper:
image: ghcr.io/kereis/traefik-certs-dumper:latest
container_name: traefik_certs_dumper
restart: unless-stopped
depends_on:
- traefik
environment:
- DOMAIN=mail.tariwiencke.de
# Stalwart läuft als UID 2000 — ohne Override wären die Dateien
# root-only und damit unlesbar.
- OVERRIDE_UID=2000
- OVERRIDE_GID=2000
volumes:
- /opt/traefik:/traefik:ro
- certs:/output
volumes:
certs:
name: traefik_certs
DOMAIN muss der erste Hostname der Router-Regel sein. Traefik speichert das kombinierte SAN-Zertifikat unter seiner „Main Domain“ — das ist der erste Host()-Name im Router. Steht hier ein anderer Name, findet der Dumper nichts. Diese Kopplung ist unsichtbar, bis man die Router-Regel umsortiert.
Certificate-Objekt in Stalwart
Das gemountete Volume allein reicht nicht — Stalwart muss es auch benutzen:
- Settings → TLS → Certificates → Add
- Certificate:
File, Pfad/data/certs/cert.pem - Private Key:
File, Pfad/data/certs/key.pem
- Certificate:
- Settings → Network → General:
defaultCertificateIdauf die neue Certificate-ID setzen. - Speichern, dann
docker compose restart stalwart.
Detail, das Zeit kostet: Weil DOMAIN nur eine einzelne Domain dumpt, landen die Dateien flach in /output — nicht in einem <domain>/-Unterordner, wie es die allgemeinen Beispiele mit mehreren Domains zeigen.
Der Schritt, den fast jede Anleitung vergisst: der Reload
Stalwart liest Zertifikate nur beim Laden ein — nicht, wenn sich die Datei ändert. Traefik erneuert alle ~60 Tage, der Dumper schreibt neue PEMs, und Stalwart hält weiter das alte im Speicher. Irgendwann läuft dann ein abgelaufenes Zertifikat auf den Mailports, obwohl auf der Platte längst ein gültiges liegt.
Das ist die Stelle, an der ich am längsten gebraucht habe, sie überhaupt als Problem zu erkennen — denn der offizielle Traefik-Guide erwähnt sie nicht. Er baut exakt dieses Setup, certs-dumper inklusive, und sagt zur Erneuerung nichts. Der Beleg steht auf einer anderen Seite derselben Doku, unter Certificates:
„When a certificate file is rotated on disk, the running server needs to be told to re-read it. This can be triggered without stopping the server from the WebUI or the CLI.“
Dazu passt der Einleitungssatz derselben Seite: „Stalwart parses each certificate at load time“ — beim Laden, nicht bei jedem Handshake. Und es erklärt, warum es überhaupt eine dedizierte Action mit eigener Berechtigung gibt. Für etwas, das von selbst passiert, baut niemand eine Action.
Lehre, die über Stalwart hinausgeht: „Das stünde im Guide, wenn es wichtig wäre“ ist eine verführerische, aber unzuverlässige Annahme. Dokumentation ist themenweise organisiert — Zertifikatsverhalten steht auf der Zertifikatsseite, nicht im Proxy-Guide. Ein Guide, der jede Wechselwirkung mit anderen Seiten wiederholt, wäre unlesbar.
Die Lösung: täglicher Reload per Cron
#!/bin/bash
set -euo pipefail
source /root/.stalwart-reload-env # STALWART_URL, STALWART_TOKEN, HC_PING_URL
STATE_FILE="/var/lib/stalwart-tls-reload.fingerprint"
# Volume-Mountpoint bei Docker erfragen statt /var/lib/docker/volumes/… zu raten
CERT_DIR=$(docker volume inspect traefik_certs --format '{{.Mountpoint}}')
CERT_FILE="$CERT_DIR/cert.pem"
[ -s "$CERT_FILE" ] || { echo "FEHLER: $CERT_FILE fehlt oder leer"; exit 1; }
CURRENT=$(sha256sum "$CERT_FILE" | cut -d' ' -f1)
PREVIOUS=$(cat "$STATE_FILE" 2>/dev/null || echo "")
if [ "$CURRENT" = "$PREVIOUS" ]; then
echo "$(date -Is) Zertifikat unverändert, kein Reload nötig."
else
echo "$(date -Is) Zertifikat geändert, reloade."
docker run --rm --network traefik-net \
-e "STALWART_URL=$STALWART_URL" \
-e "STALWART_TOKEN=$STALWART_TOKEN" \
stalwartlabs/cli:1.0.10 \
create Action/ReloadTlsCertificates --json '{}'
mkdir -p "$(dirname "$STATE_FILE")"
echo "$CURRENT" > "$STATE_FILE"
fi
curl -fsS -m 10 --retry 5 "$HC_PING_URL"
30 4 * * * /opt/stalwart/reload-tls-certificates.sh >> /opt/stalwart/reload-tls-certificates.log 2>&1
Der Aufbau geht auf einen Forum-Post von Michael „Bytewurm“ zurück, dort für Caddy. Bei Traefik entfällt sein Kopierschritt, weil sich Dumper und Stalwart bereits dasselbe Volume teilen.
Vier Details, die den Unterschied zwischen „läuft“ und „lief noch, als es drauf ankam“ ausmachen:
- Der Ping feuert bei jedem erfolgreichen Lauf, nicht nur beim Reload. Sonst schlägt der Dead-Man's-Switch an den ~59 von 60 Tagen an, an denen sich nichts ändert.
- Der Fingerprint wird erst nach erfolgreichem Reload geschrieben.
set -euo pipefailbricht vorher ab. Andernfalls würde ein Fehlversuch den neuen Hash festschreiben und der nächste Lauf hielte die Sache für erledigt — ein stiller Fehler, der genau bis zum Ablaufdatum unsichtbar bleibt. - Der Aufruf geht an Traefik vorbei, direkt an
stalwart_app:8080im internen Netz. So funktioniert der Reload auch dann, wenn Traefik selbst das Problem ist — etwa nach einer Selbstaussperrung per Auto-Ban (Kapitel 26). - Eigener API-Token mit genau drei Berechtigungen:
authenticate,sysActionCreate,actionReloadTlsCertificates. Kein Admin-Token in einem unbeaufsichtigten Cron.
Prüfen, ob es wirklich wirkt — das Ablaufdatum im Speicher gegen das auf der Platte. Nach einer Erneuerung müssen beide identisch sein:
stalwart-cli query Certificate --json | grep -o '"notValidAfter":"[^"]*"'
docker exec stalwart_app openssl x509 -in /data/certs/cert.pem -noout -enddate
Genau dieser Vergleich ist auch der bessere Monitoring-Check als ein Kalendereintrag: Er misst das Symptom, nicht die Vermutung.
Die elegantere Lösung, die ich mir für später aufhebe: Stalwart kann Zertifikate per DNS-01 selbst holen — dann entfallen certs-dumper, das Volume, das Certificate-Objekt und dieser Cron komplett. Der Grund, warum ich das anfangs verworfen hatte (Port 80/443 gehören Traefik, und mein DNS-Anbieter hätte keine API), ist inzwischen hinfällig: Seit der automatischen DNS-Verwaltung läuft ohnehin ein Provider-Objekt mit API-Zugang, und DNS-01 braucht überhaupt keine Ports.
16. SPF konfigurieren
SPF (Sender Policy Framework) ist ein TXT-Record, der sagt: Diese Server dürfen Mail im Namen meiner Domain versenden. Empfängerserver prüfen die IP des einliefernden Servers gegen diese Liste.
Der Record
v=spf1 mx -all
Kurz und vollständig. Die beiden Teile:
mx— autorisiert automatisch alle Server, auf die der MX-Record zeigt. Da bei mir MX und sendender Server derselbe Host sind, deckt das alles ab. Keinip4:-Eintrag nötig, der bei jedem IP-Wechsel nachgezogen werden müsste.-all— Hard-Fail: Alles, was nicht autorisiert ist, soll abgelehnt werden.
Der Übergang von ~all zu -all
Für den Cutover hatte ich ~all (Soft-Fail) geplant: nicht autorisierte Absender werden markiert, aber nicht abgelehnt. Das ist die richtige Wahl für eine Übergangsphase, in der man noch nicht sicher weiß, ob irgendein vergessener Dienst in eigenem Namen sendet — ein Newsletter-Tool, ein Ticketsystem, ein Monitoring-Alert.
Was tatsächlich passiert ist: Die automatische DNS-Verwaltung hat direkt v=spf1 mx -all veröffentlicht. Ein Feld zur Abschwächung habe ich im Objektmodell nicht gefunden — das ist Stalwarts fester Default. Da ein manueller Override beim nächsten Sync ohnehin überschrieben würde, habe ich es akzeptiert.
Das ist ein realer Trade-off, kein Detail. Wer die Automatik nutzt, gibt die Kontrolle über die Strenge der Policy ab und startet sofort mit Hard-Fail. Wer eine sanfte Übergangsphase braucht, muss bei manueller DNS-Verwaltung bleiben — und dann eben DKIM-Rotationen selbst nachziehen.
Testen
dig TXT tariwiencke.de +short
Nach dem ersten Versand prüfen, ob der Empfänger spf=pass meldet — bei Gmail über „Original anzeigen“, im Header Authentication-Results.
Häufiger Fehler: Zwei SPF-Records auf derselben Domain sind ungültig — die Prüfung schlägt dann mit permerror fehl, nicht etwa „der striktere gewinnt“. Nach dem Cutover kontrollieren, dass der alte Record wirklich weg ist.
17. DKIM konfigurieren
DKIM signiert ausgehende Mail kryptografisch. Der Empfänger holt den Public Key aus dem DNS und prüft die Signatur. Anders als bei SPF überlebt DKIM das Weiterleiten von Mail — deshalb ist es das wichtigere der beiden Verfahren.
Automatische Schlüsselverwaltung
Der Wizard hat die Keys bereits erzeugt. Stalwart verwaltet ihren Lebenszyklus selbst:
| Einstellung | Mein Wert | Bedeutung |
|---|---|---|
| Algorithmen | RSA-SHA256 und Ed25519-SHA256 | zwei parallele Signaturen |
selectorTemplate | v{version}-{algorithm}-{date-%Y%m%d} | ergibt z. B. v1-rsa-20260718 |
rotateAfter | 90 Tage | nach dieser Zeit wird ein neuer Schlüssel erzeugt und übernimmt das Signieren |
retireAfter | 7 Tage | so lange bleibt der Public Key des abgelösten Schlüssels noch im DNS veröffentlicht |
deleteAfter | 30 Tage | so lange bleibt das Schlüsselmaterial serverseitig gespeichert, bevor es endgültig gelöscht wird |
Warum zwei Algorithmen? Ed25519 ist kürzer und moderner, wird aber noch nicht von allen Empfängern unterstützt. RSA daneben ist die Kompatibilitätsversicherung. Signiert wird mit beiden; der Empfänger nimmt, was er versteht.
Der entscheidende Wert ist retireAfter, nicht deleteAfter. Beim Wechsel hört der alte Schlüssel sofort auf zu signieren — er geht in den Zustand „retiring“. Eine Mail kann aber Tage unterwegs sein, und der Empfänger prüft die Signatur erst bei der Zustellung. Würde der alte Public Key gleichzeitig aus dem DNS verschwinden, wären alle noch laufenden Mails plötzlich nicht mehr verifizierbar. Genau dafür bleibt er die sieben Tage stehen.
deleteAfter betrifft dagegen nur den Server: wie lange das ausgediente Schlüsselmaterial dort noch liegt, bevor es gelöscht wird. Für die Zustellbarkeit spielt der Wert keine Rolle.
Genau diese Rotation ist das stärkste Argument für die automatische DNS-Verwaltung: Alle 90 Tage zwei TXT-Records von Hand nachzuziehen und den alten zum richtigen Zeitpunkt zu löschen, vergisst man garantiert.
Signiert wird auch ohne DNS-Record
Ein Verständnispunkt, der beim Testen Verwirrung stiftet: Stalwart signiert ausgehende Mail, sobald die Keys existieren — unabhängig davon, ob der DNS-Record schon live ist. Signieren und Verifizierbarkeit sind zwei getrennte Dinge.
Praktisch heißt das: Der DKIM-Signature-Header mit d=tariwiencke.de taucht sofort in der gesendeten Mail auf, aber Authentication-Results beim Empfänger zeigt dkim=fail oder permerror, solange die TXT-Records fehlen. Kein Bug.
Manuell veröffentlichen
Wer bei manueller DNS-Verwaltung bleibt: Die fertigen Record-Werte stehen im Web-UI unter der Domain. Im Hostname-Feld nur <selector>._domainkey eintragen, ohne Domain-Suffix — die meisten DNS-Oberflächen hängen die Zone selbst an, sonst landet man bei v1-rsa-20260718._domainkey.tariwiencke.de.tariwiencke.de.
Testen
dig TXT v1-rsa-20260718._domainkey.tariwiencke.de +short
dig TXT v1-ed25519-20260718._domainkey.tariwiencke.de +short
18. DMARC konfigurieren
DMARC verbindet SPF und DKIM zu einer Policy und ergänzt zwei Dinge, die beiden einzeln fehlen: Alignment (die Domain im From:-Header muss zur geprüften Domain passen) und Reporting.
Die drei Policies
| Policy | Wirkung | Wann |
|---|---|---|
p=none | nur beobachten, nichts ablehnen | Einstieg — Reports sammeln |
p=quarantine | Fehlschläge in den Spam-Ordner | Zwischenstufe |
p=reject | Fehlschläge ablehnen | Zielzustand |
Der empfohlene Weg ist eindeutig: mit p=none starten, ein bis zwei Wochen die Aggregate-Reports lesen, dann schrittweise verschärfen. Sonst landet man im schlechtesten Fall dort, wo man selbst legitime Mail eines vergessenen Dienstes hart abweist.
v=DMARC1; p=none; rua=mailto:postmaster@tariwiencke.de
Was bei mir tatsächlich veröffentlicht wurde
p=reject. Sofort, ohne Übergangsphase.
Wie bei SPF gibt es kein Feld für die Policy-Stärke — stalwart-cli describe Domain zeigt keines. Das ist der feste Default der automatischen DNS-Verwaltung.
Meine Entscheidung, das zu akzeptieren, und warum sie diskutabel ist: Der Grund für die Automatik war ja gerade, keine Records mehr von Hand nachzuziehen; ein manuelles Überschreiben würde beim nächsten Sync verworfen. Gleichzeitig heißt p=reject ohne vorherige Reportphase: Wenn irgendein Dienst in meinem Namen sendet, von dem ich nichts mehr weiß, wird dessen Mail ab sofort hart abgelehnt — und ich erfahre davon nur über die Reports, die ich dann auch lesen muss.
Bei einer Ein-Personen-Domain mit genau einem sendenden Server ist das Risiko überschaubar. Bei einer Domain mit CRM, Newsletter-Tool und Ticketsystem wäre es fahrlässig. Wer in dieser Lage ist, sollte DNS manuell verwalten, sauber über none → quarantine → reject gehen und die DKIM-Rotation als bewussten Mehraufwand einkalkulieren.
Reports lesen
rua= sammelt Aggregate-Reports: tägliche XML-Dateien der Empfängerserver darüber, wie viel Mail unter dieser Domain ankam und wie sie SPF/DKIM bewertet haben. Die Rohdateien sind für Menschen unlesbar — und genau hier spart Stalwart einen ganzen Werkzeugkasten ein.
Möglicherweise brauchst du weder parsedmarc noch einen externen Report-Dienst. Stalwart nimmt eingehende DMARC-, DKIM-, SPF- und TLS-Reports selbst an und analysiert sie automatisch; das Ergebnis landet laut Dokumentation als Ereignis im Tracing bzw. in OpenTelemetry. Ob und wie ausführlich die Reports zusätzlich im Web-UI dargestellt werden, hängt von der Version ab — sieh selbst nach, bevor du dich darauf verlässt.
Voraussetzung ist, dass die rua=-Adresse auf ein Postfach des eigenen Servers zeigt und zur Liste der eingehenden Report-Adressen passt (ReportSettings.inboundReportAddresses, Default postmaster@*). Passt sie nicht, landen die Reports als normale Mail im Postfach, statt analysiert zu werden. Reports zu lesen ist der einzige Weg, zu bemerken, dass jemand die Domain missbraucht — oder dass die eigene Konfiguration kaputt ist. Bei p=reject ohne vorherige Monitoring-Phase (siehe oben) ist es sogar der einzige Weg, überhaupt zu erfahren, wessen Mail man gerade hart ablehnt.
19. MTA-STS und TLS-Reporting
SMTP verhandelt TLS opportunistisch: Klappt es nicht, wird im Klartext zugestellt. Das macht Downgrade-Angriffe möglich — ein Angreifer in der Mitte streicht die STARTTLS-Ankündigung, und beide Seiten fallen zurück.
MTA-STS schließt diese Lücke. Der sendende Server holt eine Policy und weiß danach: Für diese Domain ist TLS mit gültigem Zertifikat Pflicht.
Was dafür nötig ist
- Ein TXT-Record an
_mta-sts.tariwiencke.denach dem Musterv=STSv1; id=<version>. - Eine Subdomain
mta-sts.tariwiencke.de, die per HTTPS ein Policy-Dokument unter/.well-known/mta-sts.txtausliefert — mitversion,mode, einer oder mehrerenmx-Zeilen undmax_age.
Beides erzeugt Stalwart selbst, sobald mtaSts in publishRecords steht: den TXT-Record über die DNS-API, das Policy-Dokument über den HTTP-Listener. Deshalb steht mta-sts.tariwiencke.de in der Traefik-Router-Regel aus Kapitel 9. Den tatsächlich ausgelieferten Inhalt sollte man einmal abrufen statt annehmen — er hängt von der Version und der Domain-Konfiguration ab:
curl https://mta-sts.tariwiencke.de/.well-known/mta-sts.txt
Die id ist eine Versionsnummer. Ändert sich die Policy, muss sie hochgezählt werden, sonst benutzen sendende Server ihre zwischengespeicherte Fassung weiter — bis zu max_age Sekunden lang.
Achtung bei mode: enforce: Ist die Policy scharf und das Zertifikat abgelaufen oder passt nicht zum MX-Hostnamen, lehnen sendende Server die Zustellung ab, statt in Klartext zurückzufallen. Aus einem abgelaufenen Zertifikat wird damit kein Schönheitsfehler, sondern toter Mailempfang — und zwar bis zu max_age lang, weil die Policy beim Absender zwischengespeichert ist. Deshalb gehört der Reload-Cron aus Kapitel 15 eingerichtet, bevor MTA-STS auf enforce geht, nicht danach. Wer sich unsicher ist, startet mit testing statt enforce und wertet erst die TLS-Reports aus.
TLS-Reporting
_smtp._tls.tariwiencke.de. TXT "v=TLSRPTv1; rua=mailto:postmaster@tariwiencke.de"
Empfängerserver schicken damit tägliche Berichte über TLS-Verhandlungen mit dem eigenen Server. Nützlich vor allem als Frühwarnung: Wenn plötzlich Fehlschläge auftauchen, stimmt etwas mit dem Zertifikat nicht — und zwar bevor Nutzer es melden.
Verifizieren
dig TXT _mta-sts.tariwiencke.de +short
dig TXT _smtp._tls.tariwiencke.de +short
curl https://mta-sts.tariwiencke.de/.well-known/mta-sts.txt
20. DANE und DNSSEC (optional)
DANE geht einen Schritt weiter als MTA-STS: Statt sich auf Zertifizierungsstellen zu verlassen, wird das Zertifikat kryptografisch im DNS verankert — per TLSA-Record. Das setzt DNSSEC voraus, denn ohne signierte DNS-Antworten wäre der TLSA-Record selbst fälschbar.
Stalwart kann dabei mehr, als man erwartet: Es erzeugt TLSA-Records für die von ihm genutzten Zertifikate automatisch — sichtbar unter Management → Directory → Domains. Ob sie bei einer Zertifikatserneuerung auch automatisch nachgezogen werden, konnte ich in der Dokumentation nicht belegt finden; die Editionsübersicht listet eine automatische TLSA-Aktualisierung, die Referenzseite zu DANE erwähnt sie nicht. Für die ausgehende Seite steuert das Feld dane auf der TLS-Strategie (optional / require / disable), ob Stalwart TLSA-Records anderer Domains prüft. require ist als globaler Standard ausdrücklich nicht empfohlen.
Bei mir ist trotzdem beides nicht eingerichtet, und das ist eine bewusste Entscheidung. Die ehrliche Begründung:
- Die Automatik greift in diesem Setup nicht. Sie hängt daran, dass Stalwart die Zertifikatserneuerung mitbekommt. Hier holt aber Traefik die Zertifikate,
traefik-certs-dumperlegt sie als Datei ab — und Stalwart lädt Datei-Zertifikate nicht selbständig neu (Kapitel 15). Ein TLSA-Record, der auf einem nicht neu geladenen Zertifikat basiert, ist genauso veraltet wie das Zertifikat selbst. Passend dazu: In meiner Konfiguration stehtcertificateManagementaufManual, undtlsafehlt in der Liste der automatisch veröffentlichten Record-Typen. - Der Fehlerfall ist unangenehm. Läuft der TLSA-Record aus dem Takt, wird Mail abgelehnt — DANE ist strikt, es gibt keinen Rückfall.
- DNSSEC ist Voraussetzung und liegt beim DNS-Provider, nicht beim Mailserver.
- MTA-STS deckt denselben Angriff bereits ab, ohne DNSSEC vorauszusetzen und ohne Ablehnung als Fehlerfall.
Wer DANE will, sollte deshalb zuerst die Zertifikatsfrage anders lösen: entweder Stalwart selbst ACME machen lassen (dann greift die TLSA-Automatik wie vorgesehen) oder den Reload aus Kapitel 15 zuverlässig automatisieren. Danach DNSSEC beim Provider aktivieren, per dig +dnssec verifizieren, und erst dann TLSA für Port 25 (_25._tcp.mail.tariwiencke.de) scharf schalten.
Für ein Ein-Personen-Setup mit Traefik davor ist das Aufwand-Nutzen-Verhältnis schlecht. Für einen Anbieter, der Mail für Dritte annimmt — oder für ein Setup ohne Reverse Proxy, in dem Stalwart seine Zertifikate selbst verwaltet — sieht die Rechnung deutlich anders aus.
21. Clients verbinden
Die Zugangsdaten
| Einstellung | Wert |
|---|---|
| IMAP-Server | mail.tariwiencke.de, Port 993, SSL/TLS |
| SMTP-Server | mail.tariwiencke.de, Port 465, SSL/TLS |
| Benutzername | die vollständige Mailadresse |
| CalDAV/CardDAV | https://mail.tariwiencke.de |
Kein Port 587 und kein Port 143. Beide sind in diesem Setup bewusst nicht vorhanden: Stalwart hat serverseitig keine Listener dafür (der Wizard legt sie nicht an), sie sind nicht Teil des offiziellen Traefik-Guides, und 465/993 mit implizitem TLS decken dieselbe Funktionalität ab. Ein Verbindungsversuch auf 587 liefert „Connection refused“ — kein Fehler, sondern Absicht.
Autodiscovery — vier konkurrierende Verfahren
Hier wird es unangenehm, weil jeder Client etwas anderes macht:
| Verfahren | Wer nutzt es | Wo es sucht |
|---|---|---|
| Autoconfig (Mozilla) | Thunderbird | autoconfig.<domain> |
| Autodiscover (Microsoft) | Outlook | autodiscover.<domain> |
PACC (IETF-Entwurf draft-ietf-mailmaint-pacc, Pfad nach RFC 8615) | neuere Clients | <domain>/.well-known/user-agent-configuration.json |
| Geraten | Apple Mail | imap.<domain>, smtp.<domain> |
Stalwart implementiert die ersten drei. Den vierten Fall implementiert niemand — er funktioniert nur, wenn DNS und Zertifikat zufällig passen. Die Arbeit liegt darin, dass die Anfragen überhaupt ankommen.
Autoconfig/Autodiscover funktionieren per Subdomain-Konvention. Bei mir zeigten beide über einen *-Wildcard-A-Record auf die VPS; einen vom alten Hoster angelegten autoconfig-CNAME musste ich löschen, damit der Wildcard greift — sonst hätten neue Kontoeinrichtungen weiter die alten Einstellungen bekommen. Beide Namen stehen zusätzlich in der Traefik-Router-Regel, damit das Zertifikat sie abdeckt.


Oben der Wildcard, der alles Nötige auflöst — unten der CNAME des alten Hosters, der ihn für genau diesen Namen aushebelte. Ein spezifischerer Record gewinnt immer gegen den Wildcard.
Diese Labels gehören an den Stalwart-Service, nicht in die Traefik-Datei — wie alle Router-Labels in diesem Setup.
PACC und CalDAV/CardDAV-Discovery liegen laut Spezifikation auf der nackten Domain — also tariwiencke.de, nicht mail.tariwiencke.de. Diese Domain bedient bei mir aber die Portfolio-Website. Lösung: ein pfadbeschränkter Traefik-Router, der genau diese Pfade an Stalwart gibt und alles andere unberührt lässt:
- traefik.http.routers.stalwart-apex-fallback.rule=Host(`tariwiencke.de`) && (Path(`/.well-known/user-agent-configuration.json`) || Path(`/.well-known/caldav`) || Path(`/.well-known/carddav`) || Path(`/.well-known/mail-v1.xml`) || Path(`/.well-known/autoconfig/mail/config-v1.1.xml`) || PathPrefix(`/dav/`))
- 'traefik.http.routers.stalwart-apex-fallback.entrypoints=websecure'
- 'traefik.http.routers.stalwart-apex-fallback.tls=true'
- 'traefik.http.routers.stalwart-apex-fallback.tls.certresolver=myresolver'
- 'traefik.http.routers.stalwart-apex-fallback.service=stalwart'
- 'traefik.http.routers.stalwart-apex-fallback.priority=1000'
Bewusst nicht tariwiencke.de in die mailserver-Router-Regel aufnehmen — das würde die gesamte Domain kapern statt nur diese Pfade. Die explizite priority=1000 sorgt dafür, dass dieser spezifischere Router immer vor dem generischen Website-Router gewinnt; Traefiks Default-Priorität hängt an der Regel-Länge, und die des anderen Routers liegt in einem separaten Repo, ist hier also nicht kontrollierbar.
PathPrefix(/dav/) ist der Teil, den ich zuerst vergessen hatte. .well-known/caldav antwortet nicht direkt, sondern per Redirect auf /dav/cal — und Stalwart baut diesen Redirect aus dem Host-Header der Anfrage. Eine Anfrage mit Host: tariwiencke.de wird also auf https://tariwiencke.de/dav/cal umgeleitet. Ohne den Prefix landet der Client dort auf der Website und bekommt einen 404. Die Well-known-Pfade allein haben das Problem nur um einen Hop verschoben.
Apple Mail rät. Für IMAP/SMTP nutzt Apples automatische Einrichtung keines der drei Verfahren, sondern probiert direkt imap.<domain> und smtp.<domain>. Beide lösten über den Wildcard bereits auf und erreichten Stalwart auch — die TCP-Router greifen ja per HostSNI(*) unabhängig vom Namen. Nur deckte das ausgelieferte Zertifikat diese beiden Namen nicht ab, die Hostname-Verifikation schlug fehl, und Apple Mail brach ab. Fix: beide Namen in die mailserver-Router-Regel, damit das SAN-Zertifikat sie enthält. Keine DNS-Änderung nötig.
⚠️ Auf 465/993 reicht das noch nicht. Der Router-Fix erweitert zunächst nur das Zertifikat, das Traefik ausliefert. Auf den Passthrough-Ports terminiert Stalwart selbst, aus der Datei — und lädt sie nicht von allein neu (Kapitel 15). Bis der Reload gelaufen ist, präsentiert Stalwart dort weiterhin das alte SAN, und der Test unten schlägt fehl, obwohl die Konfiguration längst stimmt.
echo | openssl s_client -connect imap.tariwiencke.de:993 \
-servername imap.tariwiencke.de 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
Kalender und Kontakte
Läuft über denselben HTTP-Listener wie Web-UI und JMAP, also ohne zusätzliche Infrastruktur. Autodiscovery unter /.well-known/caldav bzw. /.well-known/carddav, direkter Pfad /dav/cal/<account> bzw. /dav/card/<account> als Fallback. Ein Standardkalender wird beim ersten Zugriff automatisch angelegt.
In Apple Kalender/Kontakte: Account hinzufügen → „Anderer CalDAV-Account“ → Kontotyp Automatisch. Falls das scheitert, Manuell mit Server mail.tariwiencke.de — dieser Fallback funktioniert immer.
22. Mailversand testen
Fünf Tests, in dieser Reihenfolge. Jeder prüft etwas anderes, und jeder kann für sich fehlschlagen.
1. Eingehend mit swaks
swaks --to admin@tariwiencke.de --from test@extern.example \
--server mail.tariwiencke.de --port 25
Wenn das hängt, liegt es meist nicht am Server. Viele Heim-Internetanschlüsse blockieren ausgehenden Port 25 als Anti-Spam-Maßnahme — das äußert sich als Timeout, nicht als Fehlermeldung. Zum Eingrenzen:
nc -zv -w5 gmail-smtp-in.l.google.com 25
Schlägt auch das fehl, liegt es am eigenen Netzwerk. Dann per SSH auf den Server und den Test von dort ausführen.
2. Ausgehend mit swaks
swaks --to <externe-adresse> --from admin@tariwiencke.de \
--server mail.tariwiencke.de --port 465 \
--auth LOGIN --auth-user admin@tariwiencke.de \
--auth-password '<passwort>' \
--tls-on-connect
--tls-on-connect ist Pflicht — Port 465 spricht ab dem ersten Byte TLS.
3. Header der empfangenen Mail prüfen
Bei Gmail „Original anzeigen“. Zu sehen sein sollte:
DKIM-Signature: v=1; a=rsa-sha256; d=tariwiencke.de; s=v1-rsa-20260718; ...
Authentication-Results: mx.google.com;
dkim=pass header.i=@tariwiencke.de;
spf=pass ...;
dmarc=pass ...
dkim=fail bei vorhandenem DKIM-Signature-Header heißt: Signatur da, DNS-Record fehlt oder ist falsch.
4. Externe Prüfdienste
- mail-tester.com — Mail an die angezeigte Wegwerfadresse senden, Score abrufen. Prüft SPF, DKIM, DMARC, PTR, Blacklists und Spam-Score in einem Durchgang.
- check-auth@verifier.port25.com — antwortet mit einem detaillierten Authentifizierungsbericht.
Nicht zu früh testen. Vor dem Cutover ist ein niedriger Score strukturell unvermeidbar: Solange der SPF-Record noch den alten Hoster autorisiert, kann SPF-Alignment gar nicht bestehen. Das ist kein Bug, sondern die Konsequenz der Koexistenz-Strategie.
5. Die echte Client-IP prüfen
Der Test, der spezifisch dieses Traefik-Setup validiert:
docker compose logs stalwart | grep -i "remoteIp"
Steht dort bei eingehenden Mails 172.20.0.2 statt der echten Absender-IP, funktioniert PROXY Protocol nicht — und damit laufen SPF-Prüfung, Rate-Limiting und Auto-Banning gegen die falsche Adresse. Siehe Kapitel 26.
23. Monitoring
Ein Mailserver fällt leise aus. Niemand ruft an, um zu sagen, dass die Mail nicht ankam.
Logs
docker compose logs -f stalwart
docker compose logs --since 1h stalwart | grep -i error
Weil im Wizard Console gewählt wurde (Kapitel 11), landet alles im Docker-Log-Treiber. Zwei Ereignistypen, die bei mir real aufgetreten sind: network.proxy-error und security.ip-blocked. Welche Typen dein Server sonst noch schreibt, zeigt am schnellsten ein Blick ins laufende Log.
Log-Rotation nicht vergessen. Dockers Default-json-file-Treiber wächst unbegrenzt. In /etc/docker/daemon.json:
{ "log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" } }
Zwei Schichten, und warum eine nicht reicht
| Schicht | Wo | Beantwortet |
|---|---|---|
| healthchecks.io (Dead-Man's-Switch) | extern | „Lebt die Kiste? Laufen die Crons?“ |
| Uptime Kuma (aktives Monitoring) | auf dem Server | „Welcher Dienst genau hat ein Problem?“ |
Angefangen habe ich nur mit der ersten: Jedes Backup-Skript pingt nach erfolgreichem Lauf eine URL, bleibt der Ping aus, kommt eine Meldung. Billig und erstaunlich wirksam — ist der Server tot, erfahre ich es binnen 24 Stunden, ohne selbst Monitoring zu betreiben.
Zwei reale Vorfälle haben gezeigt, dass das nicht reicht: Der kaputte MX-Record (Kapitel 27) blieb zwei Wochen unbemerkt, und bei einer Selbstaussperrung (Kapitel 26) waren Web-UI und JMAP komplett down, während alle Backups fröhlich weiter grün pingten. Beides Ausfälle, die ein Cron-Ping strukturell nicht sehen kann.
Warum beide Schichten nötig sind: Ein Monitor auf der überwachten Maschine kann deren Ausfall nicht melden — stirbt der Server, stirbt der Monitor mit. Das ist keine Schwäche der Software, sondern Logik. Der externe Dead-Man's-Switch bleibt deshalb die äußere Schicht, auch nachdem das aktive Monitoring steht.
Die Checks, die bei einem Mailserver zählen
- HTTP(S) auf alle Weboberflächen, Intervall 60 s.
- Zertifikatsablauf auf 465 und 993, Warnung ab 14 Tagen Restlaufzeit. Bewusst nicht auf Port 25: Dort läuft STARTTLS, beim Verbindungsaufbau gibt es also noch gar kein Zertifikat zu prüfen (Kapitel 9). Port 25 gehört trotzdem überwacht — aber als reiner Erreichbarkeitstest, nicht als Zertifikatscheck. Das ist der wichtigste Check in diesem Setup — und er muss auf 465/993 laufen, nicht auf 443. Auf 443 liefert Traefik aus, das erneuert sich selbst und hat das Problem nie. Auf 465/993 liefert Stalwart aus einer Datei aus, die nur der Cron aus Kapitel 15 aktuell hält. Dessen healthchecks-Ping sagt „Skript gelaufen“, nicht „Zertifikat aktuell“.
- DNS-Records, und das ist der eigentliche Gewinn: MX, SPF, DMARC und der A-Record des Mail-Hostnamens. Uptime Kuma kann den Wert prüfen und alarmieren, wenn er sich ändert. Mein MX-Vorfall wäre damit in Minuten aufgefallen statt in zwei Wochen. Wichtig: einen zweiten Monitor direkt gegen die autoritativen Nameserver laufen lassen, sonst misst man einen Cache.
- Nicht die DKIM-Selektoren überwachen. Die rotieren alle 90 Tage planmäßig (Kapitel 17) — ein Monitor darauf schlägt zuverlässig Fehlalarm.
Der Alarmkanal darf nicht am Mailserver hängen
Klingt offensichtlich, ist aber genau der Fehler, den man beim Einrichten macht — man trägt die eigene Adresse ein, weil man sie eben hat. Ist Stalwart das Problem, kommt die Alarm-Mail nie an.
Ich nutze ntfy über die öffentliche Instanz. Nicht selbst gehostet, und das ist Absicht: Ein ntfy auf demselben Server würde beim Ausfall dieses Servers mit ausfallen — also genau dann, wenn man den Alarm braucht. Auf der öffentlichen Instanz ist der Topic-Name das einzige Geheimnis, also einen langen zufälligen wählen, keinen sprechenden.
Eine Falle beim Aufsetzen
Sobald der Monitoring-Container läuft, ist seine Subdomain über Traefik sofort erreichbar — und die Setup-Seite, auf der man den Admin-Account anlegt, schützt sich nicht selbst. „Schnell sein“ ist keine Verteidigung: Traefik fordert beim ersten Aufruf ein Let's-Encrypt-Zertifikat an, und das landet umgehend in den öffentlichen Certificate-Transparency-Logs. Genau danach suchen Bots, um frische, noch ungeschützte Subdomains zu finden.
Deshalb die Ersteinrichtung nicht über die öffentliche URL machen, sondern den Port temporär nur auf 127.0.0.1 binden und per SSH-Tunnel zugreifen. Admin anlegen, 2FA aktivieren, dann die temporäre Bindung wieder entfernen.
Wer überwacht den Überwacher?
Die naheliegende Lücke: Stürzt der Monitoring-Container ab, ohne dass der Server selbst betroffen ist, meldet das keine der beiden Schichten. Kuma kann den eigenen Ausfall nicht melden, und der Dead-Man's-Switch sieht nur die Crons.
Die Lösung braucht keinen dritten Dienst, sondern nur eine Richtungsumkehr: Der Monitor pingt den Dead-Man's-Switch selbst an. Ein ganz normaler HTTP-Monitor in Kuma, alle fünf Minuten auf die healthchecks.io-Ping-URL. Bleibt der Ping aus, weiß die äußere Schicht, dass entweder Kuma oder der ganze Server weg ist — und meldet das über einen Kanal, der nichts mit dem Server zu tun hat.
Nebeneffekt: Der Ausfall des gesamten Servers wird damit in Minuten entdeckt statt erst beim nächsten Backup-Lauf am nächsten Morgen.
Was der Heartbeat nicht beweist: dass Kuma seine Arbeit macht. Er zeigt, dass der Prozess lebt. Sind alle Monitore pausiert, falsch konfiguriert, oder ist der Benachrichtigungskanal kaputt, bleibt er trotzdem grün. Liveness ist nicht Korrektheit. Der einzige echte Test ist, gelegentlich absichtlich einen Alarm auszulösen und zu prüfen, ob die Nachricht wirklich auf dem Handy ankommt.
Was weiterhin fehlt
Queue-Überwachung ist das, was ich inhaltlich am meisten vermisse. Eine wachsende ausgehende Queue heißt Zustellprobleme bei einem großen Anbieter und ist das früheste Signal, dass die Reputation kippt. Im Web-UI sichtbar, aber ohne Alarm.
24. Backup
Der wichtigste Abschnitt dieses Artikels. Ein Mailserver ohne getesteten Restore ist ein Datenverlust mit Vorlaufzeit.
Was gesichert werden muss — und was nicht dort liegt, wo man denkt
Weil alle vier Storage-Rollen auf Postgres zeigen (Kapitel 11), liegt praktisch die komplette Mail-Historie in der Datenbank — nicht in ./data oder ./config. Die Verzeichnisse zu sichern und die Datenbank zu vergessen wäre ein Backup ohne Mails.
| Was | Wo | Wie |
|---|---|---|
| Mails, Konten, Einstellungen | PostgreSQL | pg_dump → Restic |
| Bootstrap-Config | ./config/config.json | Restic |
| Migrationsarchive | ./archives/*.sqlite | Restic |
| Settings als lesbare Historie | Stalwart-API | stalwart-cli snapshot → Git |
Ein rohes Dateibackup des laufenden ./postgres-Verzeichnisses ist kein Backup. Ohne WAL-Archivierung oder pg_basebackup ist der Zustand nicht konsistenzsicher — man sichert eine Datenbank mitten im Schreibvorgang. Deshalb ein expliziter Dump vor dem Restic-Lauf.
Das Backup-Skript
#!/bin/bash
source /opt/stalwart/.env
source /root/.restic-env
BACKUP_DIR="/opt/stalwart/backups"
ARCHIVE_DIR="/opt/stalwart/archives"
mkdir -p "$BACKUP_DIR"
DUMP_FILE="$BACKUP_DIR/stalwart-db-backup-$(date +%Y%m%d).sql.gz"
docker exec stalwart_postgres pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
| gzip > "$DUMP_FILE"
if [ -s "$DUMP_FILE" ]; then
BACKUP_PATHS=("$BACKUP_DIR" /opt/stalwart/config)
[ -d "$ARCHIVE_DIR" ] && BACKUP_PATHS+=("$ARCHIVE_DIR")
restic backup "${BACKUP_PATHS[@]}" --host "netcup" --tag "stalwart"
# Lokale Dumps nur 7 Tage vorhalten — restic hat die volle Historie
find "$BACKUP_DIR" -name "stalwart-db-backup-*.sql.gz" -mtime +7 -delete
curl -fsS -m 10 --retry 5 https://hc-ping.com/<eigene-uuid>
else
echo "DB dump failed or empty. Skipping restic."
exit 1
fi
0 3 * * * /opt/restic/stalwart-backup.sh >> /opt/restic/stalwart-backup.log 2>&1
Zwei Details, die den Unterschied machen:
if [ -s "$DUMP_FILE" ]— läuft der Dump leer, wird Restic gar nicht erst aufgerufen. Sonst überschreibt man über Wochen hinweg gute Snapshots mit leeren. Ein stiller Backup-Fehler ist schlimmer als gar kein Backup, weil man sich in Sicherheit wiegt.- Ping nur im Erfolgsfall. Schlägt der Dump fehl, bleibt der Ping aus und der Dead-Man's-Switch schlägt an.
Settings-Snapshots in Git
Der pg_dump enthält alles, ist aber ein undurchsichtiger SQL-Dump. Um herauszufinden, was sich an einem Listener zwischen zwei Zeitpunkten geändert hat, müsste man zwei Snapshots restaurieren und Dumps vergleichen.
stalwart-cli snapshot liefert stattdessen diffbares NDJSON, das nur Settings enthält — keine Maildaten.
⚠️ Bevor du den Befehl roh benutzt: Er filtert Secrets heraus — also Passwörter und Private Keys — aber nicht alles Sensible. Bei mir landete der API-Key des DNS-Providers samt Kundennummer im Klartext in der committeten Datei, weil apiKey serverseitig kein SecretKey-Feld ist. In einem privaten Repo unschön, in einem öffentlichen ein Vorfall. Pack den Aufruf deshalb von Anfang an in ein Wrapper-Skript, das anschließend redigiert — und das abbricht, wenn es in einem unbekannten Feld etwas findet, das nach Zugangsdaten aussieht.
stalwart-cli snapshot \
Tenant Domain Directory DkimSignature AcmeProvider Certificate DnsServer Role \
Account NetworkListener Http \
SystemSettings DataRetention BlobStore InMemoryStore SearchStore \
--allow-unresolved PublicKey \
--output "settings-snapshot-$(date +%Y-%m-%d).ndjson"
Das Ergebnis wandert in Git. Kadenz: nach jeder Settings-Änderung, plus monatlich als Drift-Check. Aus einem Ad-hoc-Debugging-Trick wird so eine versionierte Historie — beim Traefik-Umbau habe ich damit zweimal reale Bugs per Diff zwischen zwei Snapshots gefunden.
Warum kein Cron auf dem Server: Der Snapshot endet in git commit und git push. Ein Cron-Job auf dem VPS, der das selbständig tut, würde das strikt einseitige Deployment-Modell umkehren (lokal ändern → pushen → Server zieht) und bräuchte Push-Credentials auf dem Server. stalwart-cli verbindet sich per HTTPS und läuft deshalb vom eigenen Rechner.
Und wenn es doch schon passiert ist — bei mir kam die Erkenntnis erst, als der Key längst in mehreren Commits lag. Prüfen lässt sich das schnell:
grep -o '"apiKey":"[^"]*"' settings-snapshot-*.ndjson
Findet sich etwas, reicht Redigieren im aktuellen Stand nicht: Die Git-Historie gibt den alten Wert weiterhin her. Der einzige wirksame Schritt ist, das betroffene Zugangsdatum beim Anbieter zu rotieren. Ein History-Rewrite ist Kosmetik, solange irgendein Klon den alten Stand hat.
Restore — drei Szenarien
Nicht jeder Vorfall braucht einen Neuaufbau.
1. Settings-Drift korrigieren (laufender Server, ein Objekt kaputtkonfiguriert):
stalwart-cli apply --file settings-snapshot-<datum>.ndjson --dry-run
stalwart-cli apply --file settings-snapshot-<datum>.ndjson
docker compose up -d --force-recreate stalwart
Getestet, mit klarem Ergebnis: Strukturelle Typen (NetworkListener, Role, DnsServer, Domain, Http, DataRetention, die Store-Objekte) wurden zuverlässig übernommen. Drei Typen scheitern systematisch:
Account— kein Passwort im Snapshot, also keine Neuanlage (Password must be at least 8 characters long). Zusätzlich hat der Typ keinen Match-Key; ein erneutes Apply würde ein bestehendes Konto eher duplizieren als aktualisieren.DkimSignature— kein Private Key im Snapshot. Updates auf bestehende Signaturen funktionieren, Neuanlage nicht.SystemSettings— referenziert andere Objekte über lokale Snapshot-IDs. Existiert das Ziel bereits unter einer anderen echten ID, bleibt die Referenz unaufgelöst:Failed to parse Id from string.
Konsequenz: Dieser Weg ist für gezielte Korrekturen da, nicht für die Neuanlage auf leerem Directory. Ich hatte ihn ursprünglich als „Server komplett neu aufsetzen“-Anleitung geschrieben — der erste echte Testlauf hat das widerlegt.
2. Einzelnes Migrationsarchiv einsehen, ohne den Server anzufassen:
source /root/.restic-env
restic snapshots --tag stalwart
restic restore latest --target /tmp/archiv-restore \
--include /opt/stalwart/archives
vandelay inspect /tmp/archiv-restore/opt/stalwart/archives/<datei>.sqlite
3. Voller Restore (Server weg):
# 1. Neuer Host, Docker installiert, Repo geholt
# 2. Restic-Zugangsdaten nach /root bringen (nie per git pull)
source /root/.restic-env
restic restore latest --tag stalwart --target /opt/stalwart-restore
cd /opt/stalwart
cp .env.example .env # Passwörter aus dem Passwortmanager
mkdir -p config data
chown -R 2000:2000 config data
source .env # WICHTIG, siehe unten
docker compose up -d db
docker compose ps # auf "healthy" warten
DUMP_FILE=$(ls /opt/stalwart-restore/opt/stalwart/backups/*.sql.gz | sort | tail -n1)
gunzip -c "$DUMP_FILE" | docker exec -i stalwart_postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"
cp /opt/stalwart-restore/opt/stalwart/config/config.json /opt/stalwart/config/
cp -r /opt/stalwart-restore/opt/stalwart/archives /opt/stalwart/
chown -R 2000:2000 /opt/stalwart/config /opt/stalwart/archives
docker compose up -d
Das source .env ist kein Schönheitsfehler, sondern ein Bug, den ich im Test gefunden habe. Ohne diese Zeile sind $POSTGRES_USER und $POSTGRES_DB leer, docker exec läuft als root (Docker-Default, unabhängig vom Image), und psql -U "" fällt auf Peer-Auth zurück: FATAL: role "root" does not exist.
Die Passwörter in der neuen .env müssen übrigens nicht mit den alten identisch sein — der Dump enthält Daten und Hashes, keine Verbindungspasswörter.
Der Punkt, den man nicht überspringen darf
Ich habe Szenario 2 und 3 real durchgespielt, nicht nur aufgeschrieben. Dabei kam der source .env-Bug heraus, und Szenario 1 musste auf einen deutlich engeren Scope zurechtgestutzt werden, als ich ursprünglich dokumentiert hatte.
Beide Erkenntnisse hätte ich im Ernstfall gemacht — unter Zeitdruck, mit toter Mail. Ein ungetestetes Backup ist eine Vermutung.
25. Updates
Regulär
cd /opt/stalwart
docker compose pull
docker compose up -d
docker compose logs -f stalwart
Weil das Image auf v0.16 gepinnt ist, holt pull nur Patches innerhalb dieser Minor-Version. Ein Sprung auf v0.17 ist eine bewusste Änderung an der Compose-Datei — genau so soll es sein, solange Stalwart vor 1.0 ist und Breaking Changes zwischen Minor-Versionen möglich sind.
Vor jedem Minor-Sprung: Release Notes lesen, Settings-Snapshot ziehen, Backup-Lauf manuell anstoßen. In dieser Reihenfolge.
Rollback
# Tag in docker-compose.yml zurücksetzen, dann:
docker compose up -d
Ein Rollback ist keine Zeitmaschine. Hat die neuere Version Datenbankmigrationen ausgeführt, kann die ältere mit dem migrierten Schema nichts anfangen. Dann führt der Weg über den DB-Restore aus Kapitel 24 — weshalb der Backup-Lauf vor dem Update stehen muss, nicht danach.
Postgres
Der Sprung auf eine neue Postgres-Hauptversion (17 → 18) funktioniert nicht durch Ändern des Image-Tags: Das Datenverzeichnis ist zwischen Hauptversionen nicht kompatibel. Der Weg ist Dump, neuer Container mit leerem Verzeichnis, Restore. Weil das Backup-Skript ohnehin täglich dumpt, ist der Dump schon da.
26. Sicherheit
Der Hardening-Schritt, der wirklich zählt
Die offizielle Dokumentation empfiehlt, nach der Erstanmeldung den HTTP-Listener auf Port 8080 zu löschen. In diesem Setup wäre das falsch — es setzt eine Topologie voraus, in der Stalwart TLS selbst auf 443 terminiert und 8080 ein verzichtbarer Bootstrap-Pfad daneben ist.
Hier ist Port 8080 aber der einzige Listener hinter dem gesamten Web-UI/JMAP-Pfad: Traefik terminiert TLS und reicht Klartext-HTTP genau dorthin weiter. Ihn zu löschen härtet nichts, sondern legt das Admin-Interface komplett lahm.
Zwei Punkte ordnen das eigentliche Risiko ein:
- Die Angriffsfläche, vor der die Doku warnt, existiert hier nicht. Port 8080 wird nie als Host-Port publiziert — kein
ports:-Eintrag. Erreichbar nur innerhalb vontraefik-net. Niemand im Internet kannhttp://<ip>:8080/adminansprechen, egal ob der Listener existiert. - Das Risiko ist
STALWART_RECOVERY_ADMINselbst, nicht der Port. Laut Doku ein reines Rescue-Credential, das „must not be left permanently set on a production deployment“ — und weil Traefik HTTPS an denselben Listener weiterreicht, funktioniert dieses Backdoor-Login ganz normal überhttps://mail.tariwiencke.de/admin.
Der einzige korrekte Hardening-Schritt:
# STALWART_RECOVERY_ADMIN-Zeile aus .env entfernen
docker compose restart stalwart
Erst danach, wenn ein normaler Admin-Account bestätigt funktioniert.
PROXY-Protocol-Vertrauen — und der Fehler, der mich ausgesperrt hat
Für jeden Mail-Listener (Settings → Network → Listeners, identifiziert über den gebundenen Port) muss overrideProxyTrustedNetworks auf Traefiks feste IP stehen:
"overrideProxyTrustedNetworks": ["172.20.0.2"]
Mein Stand: gesetzt auf smtp (25), submissions (465) und imaps (993).
Nicht auf dem http-Listener setzen. Genau das hatte ich getan, und die Kette danach war lehrreich:
Traefiks HTTP-Router zu Port 8080 sendet nie einen PROXY-Header — nur die traefik.tcp.services.*-Labels haben proxyProtocol.version=2, das HTTP-Service-Label nicht. Mit gesetztem overrideProxyTrustedNetworks erwartet Stalwart auf jeder Verbindung von dieser IP einen PROXY-Header, bekommt aber GET /admin HTTP/1.1...:
Proxy protocol error (network.proxy-error) listenerId = "http"
... reason = "invalid proxy header"
Web-UI und JMAP komplett down. Und dann kam der zweite Schritt: Die Flut ungültiger Header löste Stalwarts Auto-Ban aus — Traefiks eigene IP wurde als BlockedIp gesperrt. Weil sämtlicher Web-UI-Traffic ausschließlich über Traefik läuft, war damit auch der Weg gesperrt, über den man die Sperre aufheben würde.
Der Ausweg geht an Traefik vorbei, direkt auf Stalwarts Loopback im Container:
docker exec -it stalwart_app curl -s -u admin@tariwiencke.de:'<passwort>' \
-X POST http://localhost:8080/api -H 'Content-Type: application/json' \
-d '{"methodCalls": "x:BlockedIp/query",
{"filter": {"address": "172.20.0.2"}}, "c1",
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]}'
# mit der zurückgelieferten id:
docker exec -it stalwart_app curl -s -u admin@tariwiencke.de:'<passwort>' \
-X POST http://localhost:8080/api -H 'Content-Type: application/json' \
-d '{"methodCalls": [["x:BlockedIp/set", {"destroy": ["<id>"]}, "c1"]],
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]}'
Reihenfolge ist entscheidend: Sofort nach dem Entsperren die Ursache beheben — sonst produziert Traefik weiter ungültige Header und die IP wird binnen Sekunden erneut gesperrt.
HTTP-Sicherheitseinstellungen
Auf dem Http-Singleton (Settings → Network → HTTP):
| Feld | Wert | Warum |
|---|---|---|
enableHsts | true | Traefik terminiert TLS, HSTS ist sinnvoll |
usePermissiveCors | false | Default, hier nur bestätigen |
useXForwarded | true | sonst loggt Stalwart bei jeder Anfrage Traefiks IP |
useXForwarded hat den Default false. Ohne diesen Schritt ist das Symptom unauffällig: kein Fehler, nur konsequent die falsche IP im Log.
Ein Detail, das man verstehen sollte: Anders als bei PROXY Protocol gibt es für X-Forwarded-For keine Trusted-Networks-Liste. Einmal aktiviert, vertraut Stalwart dem Header bedingungslos von jeder Quelle. Das ist hier unbedenklich, weil der http-Listener nie direkt exponiert ist — es kann gar keine andere Quelle als Traefik eine Verbindung dorthin aufbauen. In einem Setup, in dem Port 8080 auf den Host publiziert ist, wäre dieselbe Einstellung eine offene Spoofing-Möglichkeit.
Nicht benötigte Protokolle
Serverseitig existieren nach dem Wizard mehr Listener, als von außen erreichbar sind — unter anderem ManageSieve auf 4190 und ein HTTPS-Listener auf 443. Beide haben keinen Traefik-Entrypoint und sind damit von außen nicht ansprechbar. POP3 ist gar nicht angelegt.
Das ist die eigentliche Stärke des „alles über den Reverse Proxy“-Ansatzes: Die Angriffsfläche wird an genau einer Stelle definiert — in der Entrypoint-Liste von Traefik. Was dort nicht steht, ist nicht erreichbar, unabhängig davon, was der Anwendungsserver intern anbietet.
Weiteres
-
Rate-Limits: Stalwart begrenzt HTTP-Anfragen ab Werk (anonym 100/min, authentifiziert 1000/min). Für Mail-Listener separat konfigurierbar.
-
Auto-Ban: aktiv, siehe oben — mit dem Wissen, dass die eigene Infrastruktur mitgesperrt werden kann.
-
Zwei-Faktor-Authentifizierung: Stalwart unterstützt TOTP (Authenticator-App), dazu App-Passwörter und API-Keys — jeweils mit Label, IP-Beschränkung und Ablaufdatum. Passkeys/WebAuthn habe ich nicht gefunden — die Dokumentation zur Zwei-Faktor-Authentifizierung kennt ausschließlich TOTP, und auch die Editionsübersicht listet nichts dergleichen. Das ist ein Schluss aus dem Schweigen der Doku, kein bestätigtes Nein. Für ein Admin-Interface, das im offenen Internet erreichbar ist, ist TOTP die naheliegendste Absicherung.
⚠️ Vorher wissen: TOTP greift beim interaktiven Login und bei Clients, die OAuth sprechen. Ein klassischer IMAP- oder SMTP-Client kann keinen zweiten Faktor abfragen — der braucht danach ein App-Passwort. Wer TOTP einschaltet, ohne das vorzubereiten, sperrt sich die eigenen Mail-Clients aus.
-
Secrets: Alle Passwörter kommen aus Umgebungsvariablen oder Dateien, nie aus Klartextfeldern im Web-UI.
.env-Dateien gehören in.gitignore, Backup-Zugangsdaten nach/rootmitchmod 600— bewusst außerhalb des Deployment-Checkouts.
27. Troubleshooting
Port 25 blockiert
Symptom: swaks läuft in einen Timeout, keine Fehlermeldung.
nc -zv -w5 gmail-smtp-in.l.google.com 25
Schlägt das fehl, blockiert das eigene Netzwerk oder der Anbieter Port 25. Bei Heimanschlüssen ist das der Normalfall. Beim VPS-Anbieter muss Port 25 explizit freigeschaltet werden.
„Bad Gateway“ nach dem Setup-Wizard
Zuerst der langweilige Befehl, nicht die Theorie:
docker compose ps
docker compose logs stalwart
In meinem Fall lief der Container schlicht nicht — vermutlich ein Postgres-Verbindungsproblem nach dem Neustart. Ich habe stattdessen zwei Stunden über Portkonfiguration spekuliert. docker compose ps hätte es in zwei Sekunden gezeigt.
Web-UI komplett down, Log zeigt invalid proxy header
overrideProxyTrustedNetworks steht auf dem http-Listener. Entfernen, neu starten. Siehe Kapitel 26 — und prüfen, ob Traefiks IP inzwischen gesperrt wurde.
MX-Record zeigt Unsinn
Real passiert, und das lehrreichste Problem der ganzen Migration. Zwei Wochen nach dem Cutover kam eine externe Testmail nicht an. dig zeigte:
MX 10 aaae61d6eb8d.
Ein unauflösbarer Hostname, der wie eine interne Container-ID aussieht. Zusätzlich fehlten SPF- und DMARC-TXT-Records komplett — obwohl publishRecords alle drei aktiviert hatte.
Erste, falsche Spur: mailExchangers[0].hostname stand auf null. Naheliegende Vermutung: Der dokumentierte Fallback („null bedeutet defaultHostname“) funktioniert nicht. Untermauert schien das dadurch, dass stalwart-cli get Domain <id> im berechneten Zone-File korrekt MX 10 mail.tariwiencke.de. zeigte — also eine Diskrepanz zwischen berechnetem Soll und veröffentlichtem Ist.
Was tatsächlich half: Ich habe im Web-UI die Seite Settings → Network → Services geöffnet. Kurz danach zeigte dig gegen die autoritativen Nameserver den korrekten MX-Record, dazu SPF und DMARC. mailExchangers[0].hostname steht weiterhin auf null — die ursprüngliche Vermutung war damit widerlegt, nicht bestätigt.
Der saubere Weg, den ich damals nicht kannte: Für einen erzwungenen Abgleich gibt es einen dokumentierten Mechanismus — die DnsManagement-Task unter Management → Tasks → New Task, wahlweise auf einzelne Record-Typen eingeschränkt. Das ist der reproduzierbare Fix. Mein Zufallsfund oben hat funktioniert, ist aber nichts, worauf man sich verlassen sollte. Dieselbe Dokumentationsstelle belegt übrigens auch den Kern des Problems: Synchronisiert wird beim Speichern und beim Ausführen der Task — nicht laufend.
Wahrscheinlichere Erklärung, nicht abschließend verifiziert: Der kaputte Record war eine Altlast aus der Zeit vor einer früheren Korrektur an defaultHostname. Die automatische DNS-Verwaltung veröffentlicht offenbar nur bei bestimmten Auslösern und gleicht nicht laufend gegen den Soll-Zustand ab. Ein einmal falsch veröffentlichter Record kann daher beliebig lange falsch stehenbleiben.
Zwei übertragbare Lehren:
- Immer gegen die autoritativen Nameserver prüfen, nicht gegen den lokalen Resolver — sonst diagnostiziert man einen Cache:
dig @root-dns.netcup.net MX tariwiencke.de - Soll und Ist getrennt betrachten.
stalwart-cli get Domain <id>zeigt, was der Server für richtig hält;digzeigt, was tatsächlich veröffentlicht ist. Bei einer Diskrepanz liegt der Fehler in der Publikation, nicht in der Konfiguration.
Und die unbequeme Konsequenz: Automatische DNS-Verwaltung ersetzt keine Kontrolle. Ich hatte zwei Wochen lang kaputte Zustellung, ohne es zu merken, weil ich der Automatik vertraut habe. Ein monatlicher dig-Check gehört in jedes Setup, das DNS aus der Hand gibt.
defaultHostname falsch
Betrifft direkt den HELO/EHLO-Gruß beim Versand. Steht dort ein anderer Name als im PTR-Record, ist das ein klassisches Spamfilter-Warnsignal:
stalwart-cli update SystemSettings --field defaultHostname=mail.tariwiencke.de
docker compose up -d --force-recreate stalwart
Unabhängig von STALWART_PUBLIC_URL — beide Felder haben verschiedene Zwecke und müssen beide stimmen.
Login schlägt fehl, besonders bei OAuth/Discovery
curl https://mail.tariwiencke.de/.well-known/openid-configuration
Jede URL in der Antwort muss die öffentliche Basis-URL verwenden, die auch der Browser sieht. Jede Abweichung zeigt auf ein falsches oder fehlendes STALWART_PUBLIC_URL.
Zertifikat passt nicht zum Hostnamen
echo | openssl s_client -connect mail.tariwiencke.de:993 \
-servername mail.tariwiencke.de 2>/dev/null \
| openssl x509 -noout -ext subjectAltName -dates
Fehlt ein Name: in die Traefik-Router-Regel aufnehmen, docker compose up -d in traefik/. Ist das Zertifikat abgelaufen, obwohl Traefik erneuert hat: Stalwart hat nicht neu geladen — Reload-Cron aus Kapitel 15 prüfen (Log unter /opt/stalwart/reload-tls-certificates.log), notfalls einmal manuell auslösen.
Permission denied (os error 13) beim Start
chown -R 2000:2000 /opt/stalwart/config /opt/stalwart/data
Container startet nicht nach dem Wizard
Neustart nach dem Wizard vergessen. Ohne ihn wird die geschriebene config.json nicht geladen und die Maildienste starten gar nicht:
docker compose restart stalwart
Alte Mails migrieren
Vandelay ist Stalwarts offizielles Migrationstool — es liest ein Quellkonto (IMAP, JMAP, CalDAV, CardDAV) in ein lokales SQLite-Archiv, das anschließend per JMAP auf den neuen Server exportiert wird.
vandelay import imap \
--url imaps://<quell-server> \
--auth-basic mail@tariwiencke.de \
mail_tariwiencke_de.sqlite
vandelay inspect mail_tariwiencke_de.sqlite
vandelay export \
--url https://mail.tariwiencke.de \
--auth-basic admin@tariwiencke.de \
--account-name mail@tariwiencke.de \
--dry-run \
mail_tariwiencke_de.sqlite
Als Quell-Host den Hostnamen nehmen, für den das Zertifikat gültig ist. Bei Shared Hosting passt mail.<domain> oft nicht zum Zertifikat des Anbieters; der anbietereigene Hostname schon. Das erspart --allow-invalid-certs.
Bug in Vandelay 1.0.6, den ich isoliert habe: response parse error: LIST mailbox name missing. Der LIST-Response-Parser kommt mit einem unquoted IMAP-Atom, das nur aus Ziffern besteht, nicht klar — bei mir eine Mailbox namens 200. Nach IMAP-Spezifikation ist das ein valider Name; eine manuelle IMAP-Session per openssl s_client lieferte dieselbe, syntaktisch korrekte Antwort. Eine anders ungewöhnlich benannte Mailbox (master) verursachte kein Problem.
Workaround ohne die Quelle anzufassen:
vandelay import imap --url imaps://<quell-server> \
--auth-basic mail@tariwiencke.de \
--exclude '^200$' \
mail_tariwiencke_de.sqlite
Für Gmail: Statt IMAP den Takeout-Importer nehmen — er liest einen entpackten Google-Takeout-Export (.mbox, .ics, .vcf) komplett offline, in einem Lauf für Mail, Kalender und Kontakte:
vandelay import takeout Takeout tari_gmail_com.sqlite
Zwei Eigenheiten: --dry-run zeigt hier absichtlich nur Nullen (der Takeout-Importer macht nur einen „light plan“ und liest die Dateien nicht ein — kein Fehler, keine leere Quelle). Und: jede Quelle braucht ein eigenes Archiv; Vandelay verweigert es, ein von IMAP befülltes Archiv zusätzlich aus Takeout zu füttern.
Gmail-Labels werden dabei sauber abgebildet: Starred → $flagged, Important → $important, Inbox/Sent/Drafts/Trash/Spam je zu einer Mailbox mit passender Rolle. Googles Auto-Kategorien (Category Promotions etc.) werden verworfen.
28. FAQ
Ist Stalwart kostenlos?
Ja. Stalwart ist dual lizenziert: Die Community Edition steht unter AGPL-3.0 und ist kostenlos, ohne Limits bei Postfächern oder Domains. Daneben gibt es eine kommerzielle Enterprise Edition, die vor allem operative Funktionen für größere Installationen ergänzt — unter anderem Multi-Tenancy. Für ein Setup wie dieses reicht die Community Edition vollständig.
Kann ich mehrere Domains mit einer Instanz betreiben?
Ja, und ohne Limit — die Community Edition begrenzt weder Domains noch Postfächer. Jede Domain bekommt eigene DKIM-Keys und einen eigenen DNS-Record-Satz. Zu unterscheiden davon ist Multi-Tenancy — mehrere voneinander isolierte Mandanten auf einer Instanz — das ist der Enterprise Edition vorbehalten. Ich betreibe selbst nur eine Domain und beschreibe den Mehr-Domain-Betrieb hier deshalb bewusst nicht als Anleitung.
Brauche ich zwingend Traefik?
Nein. Stalwart kann alle Ports direkt publishen und Zertifikate per eingebautem ACME-Client selbst holen — das ist sogar der einfachere Weg. Traefik lohnt sich, wenn auf demselben Server weitere Webdienste laufen und Port 80/443 bereits belegt sind. Der Preis dafür ist die PROXY-Protocol-Konfiguration aus Kapitel 26.
Warum kein Port 587 (Submission)?
Weil Port 465 mit implizitem TLS dieselbe Funktion erfüllt und der Wizard 587 gar nicht erst anlegt. Alle gängigen Clients unterstützen 465. Wer 587 braucht, muss serverseitig einen Listener anlegen und einen passenden Traefik-Entrypoint plus TCP-Router ergänzen.
Kann ich Gmail als Client nutzen?
Theoretisch ja, über Gmails „Mail von anderen Konten abrufen“ — das setzt aber POP3 voraus, und genau diesen Listener gibt es hier nicht. Sinnvoller ist der umgekehrte Weg: Gmail-Bestand einmalig per Takeout importieren (Kapitel 27) und danach einen echten IMAP/JMAP-Client nutzen.
Wie teste ich DKIM und SPF?
dig TXT <selector>._domainkey.<domain> bzw. dig TXT <domain> für die Records, dann eine Testmail und im Header Authentication-Results prüfen. Am schnellsten geht ein Rundum-Check über mail-tester.com. Siehe Kapitel 22.
Wie viel Wartung kostet das im Alltag?
Nach dem Setup wenig — Updates alle paar Wochen, gelegentlich Logs lesen. Aber es ist nicht null, und der Aufwand steckt an unerwarteten Stellen: Der Zertifikats-Reload muss einmal automatisiert werden (Kapitel 15), DNS-Records sollte man monatlich stichprobenartig prüfen, weil die Automatik veröffentlichte Records nicht laufend abgleicht (Kapitel 27), und Backups sind nur dann welche, wenn man den Restore gelegentlich testet (Kapitel 24). Drei Dinge, die jeweils zwanzig Minuten kosten — und die man genau dann bereut, wenn man sie ausgelassen hat.
Was ist mit der Zustellbarkeit zu Gmail und Outlook?
Mit korrektem PTR, SPF, DKIM und DMARC landet Mail zuverlässig im Posteingang. Was hilft: Geduld — eine frische IP hat keine Reputation und baut sie über Wochen normalen Versands auf. Was schadet: viel Mail auf einmal an eine große Liste. Wer Newsletter versendet, sollte das über einen separaten Dienst und eine separate Subdomain tun, um die Reputation der Hauptdomain nicht zu riskieren.
29. Fazit
Was gut funktioniert
- Ein Prozess für alles. Mail, Kalender, Kontakte und Adminoberfläche aus einem Binary — das ist gegenüber einem Container-Zoo ein echter Unterschied im Alltag, nicht nur auf dem Papier.
- Die Standards sind wirklich eingebaut. DKIM-Rotation, MTA-STS und TLS-Reporting sind keine Zusatzarbeit, sondern ein Häkchen.
stalwart-cliist besser als das Web-UI. Für alles, was über einzelne Klicks hinausgeht, ist die CLI zuverlässiger — mir sind mehrfach Web-UI-Formularspeicherungen nicht persistiert worden.describezeigt außerdem das komplette Objektmodell, statt es raten zu müssen.- Der Snapshot-in-Git-Ansatz. Diffbare Settings-Historie hat mir zweimal reale Bugs gezeigt, die ich sonst nicht gefunden hätte.
Was nervt
- Vor 1.0. Breaking Changes zwischen Minor-Versionen sind möglich, also ist Pinnen Pflicht und jedes Update ein bewusster Vorgang.
- Die automatische DNS-Verwaltung ist ein Alles-oder-nichts-Deal. Sie erzwingt
-allbei SPF undp=rejectbei DMARC ohne Möglichkeit zur Abschwächung — und sie gleicht bereits veröffentlichte Records nicht laufend ab. Für eine Ein-Personen-Domain akzeptabel, für eine Domain mit vielen sendenden Diensten ein echtes Risiko. - Der Zertifikats-Reload muss man sich selbst bauen. Wer Traefik statt Stalwarts eigenem ACME nutzt, braucht einen Cron dafür (Kapitel 15) — und muss erst einmal darauf kommen, dass das nötig ist, weil der offizielle Traefik-Guide es nicht erwähnt.
- Die Dokumentation ist gut, aber vollständig ist sie nur im Original. Jede meiner drei größten Fehldiagnosen kam daher, dass ich eine Zusammenfassung statt der Primärquelle gelesen habe.
Wann lohnt sich Stalwart?
Ja, wenn du selbst hosten willst, DNS verstehst oder verstehen willst, und Mail plus Kalender plus Kontakte aus einer Hand brauchst.
Nein, wenn Mail für dich geschäftskritisch ist und du keinen zweiten MX, kein getestetes Restore-Verfahren und keine Zeit für Zustellbarkeitsprobleme hast. Ein guter Managed-Hoster ist dann die ehrlichere Wahl — und das ist keine Kapitulation, sondern eine Risikoabwägung.
Der Aufwand ist real. Setup an einem Nachmittag, aber die Migration hat sich über Wochen gezogen: Cutover, Beobachtungsphase, ein zwei Wochen lang unbemerkt kaputter MX-Record, eine Selbstaussperrung. Was ich dabei über SPF, DKIM, DMARC, TLS-Passthrough und PROXY Protocol gelernt habe, war den Aufwand wert. Wer nur ein funktionierendes Postfach will, bekommt das anderswo billiger.
Weiterführende Artikel
- Ein VPS, sieben Dienste, ein Repo — der Gesamtaufbau: Traefik als einzige Kante, Backups, Monitoring.
- Immich Fotobackup einrichten — derselbe Stack, deutlich kleinerer Dienst. Guter Einstieg, wenn der Mailserver zu viel ist.
- WireGuard VPN mit wg-easy einrichten — der einzige Dienst, der bewusst am Proxy vorbeiläuft, und warum.
Quellen
- Stalwart-Dokumentation — insbesondere Reverse Proxy: Traefik, DNS-Setup und Autoconfig/PACC
- stalwartlabs/stalwart auf GitHub — Releases
- Stalwart: Editionen im Vergleich und Open Source / Lizenz
- Vandelay — Stalwarts Migrationstool
- Traefik-Dokumentation: TCP-Router und PROXY Protocol
- RFC 8615 — Well-Known URIs, RFC 6764 — CalDAV/CardDAV-Discovery
- swaks