Zum Hauptinhalt springen
S-EDV news
← Alle Anleitungen
📘 Anleitung E-Mail / Mailserver 05.11.2026 · 13 min Lesezeit

Eigener Mailserver im echten Netz: Stalwart mit SPF, DKIM, DMARC und DANE

Teil 11 der Reihe Server im Netz: Stalwart 1.0 neben Caddy im Host-Netz, eingerichtet über die API, mit SPF, DKIM, DMARC und DANE. Gemessen mit mail-tester, internet.nl und Postfächern bei Microsoft, mit zwei Fallen und der Spamhaus PBL.

Geprüft am 05.11.2026 · für Stalwart 1.0.3, Caddy 2.11.7, Unbound 1.19.2

Mit KI erstellt – redaktionelle Prüfung ausstehend

WerbelinksMit * markierte Links sind Werbelinks: Bei einem Kauf erhalten wir eine Provision, der Preis bleibt gleich. Mehr dazu

Symbolbild: Server mit leuchtendem Kabel zu einem Briefumschlag mit Schild, Titel Eigener Mailserver im echten Netz

Ein eigener Mailserver auf einem VPS ist schnell installiert. Ob andere Server seine Mails annehmen, entscheidet sich aber erst im echten Netz: an Reverse-DNS, SPF, DKIM, DMARC, an Sperrlisten und an Fallen, die erst auftreten, wenn fremde Server anklopfen. Diese Anleitung richtet Stalwart 1.0 neben einem vorhandenen Caddy ein, übernimmt den DANE-Eintrag eines bestehenden Zertifikats und misst von außen mit mail-tester.com, internet.nl und echten Postfächern bei Microsoft.

Dies ist Teil 11 der Reihe „Server im Netz“. Als Beispiel dient ein netcup-VPS mit Ubuntu 24.04 und Docker, auf dem Caddy die Ports 80 und 443 belegt und eine nftables-Firewall aus Teil 2 nur freigegebene Ports durchlässt. Die Grundinstallation von Stalwart beschreibt Stalwart 1.0 mit Docker Compose, hier geht es um den Betrieb auf einem Server, auf dem schon andere Dienste laufen.

Voraussetzungen

  • Ein VPS mit fester IPv4 und IPv6, Docker und Docker Compose, siehe Docker und Docker Compose auf Linux installieren
  • Port 25 ein- und ausgehend frei. Viele Anbieter sperren ihn bei neuen Servern, bei netcup war er nach der Freigabe offen.
  • Reverse-DNS beider Adressen auf den Hostnamen, hier mail.sedv-lab.de, siehe Reverse-DNS im netcup SCP setzen
  • Eine Domain mit DNSSEC und Zugriff auf die Zone. DANE und TLSA beschreibt DANE für den eigenen Mailserver.
  • Ein Zertifikat mit voller Kette für den Hostnamen und ein passender TLSA-Eintrag 3 1 1, wie im Artikel zu DANE beschrieben
  • Einträge MX auf mail.sedv-lab.de und A für mail
  • Ein lokaler validierender Resolver wie Unbound auf 127.0.0.1
  • Caddy für HTTPS wie in Teil 4

Schritt 1: Host-Netz statt Portweiterleitung

Die offizielle Compose-Datei veröffentlicht die Ports über das Docker-Netz. Auf diesem Server hätte das zwei Nachteile. Docker leitet veröffentlichte Ports an der Firewall vorbei: Die Pakete laufen durch die Forward-Kette, die eigene Input-Kette aus Teil 2 sieht sie nicht. Und Verbindungen über IPv6 kommen ohne IPv6 im Docker-Netz mit der internen Docker-Adresse an, wie in Teil 4 bei Caddy gemessen. Für einen Mailserver ist das schlecht, denn SPF, Sperrlisten und die Drosseln brauchen die echte Absenderadresse.

Stalwart läuft deshalb mit network_mode: host. Das Programm im Image trägt die Fähigkeit cap_net_bind_service, es darf Port 25 also auch als Nutzer 2000 ohne root öffnen. Geben Sie in der Firewall nur die Ports frei, die Sie wirklich nutzen:

sudo nft add element inet sedv offen_tcp '{ 25, 465, 993 }'

Tragen Sie die Ports auch in die Regeldatei ein, sonst fehlen sie nach dem nächsten Neustart. Port 25 ist für andere Mailserver, 465 für den Versand aus dem Mailprogramm, 993 für IMAP.

Verifizieren: sudo nft list set inet sedv offen_tcp enthält 25, 465 und 993.

Schritt 2: Compose-Datei und Einrichtung über die API

Im ersten Start ohne Konfiguration öffnet Stalwart einen Einrichtungsport, standardmäßig 8080 auf allen Adressen. Den belegt auf diesem Server schon Caddy auf 127.0.0.1. Mit STALWART_RECOVERY_MODE_PORT legen Sie ihn auf einen freien Port. Die Firewall lässt ihn von außen nicht durch, im Test lief eine Verbindung von außen ins Leere.

sudo mkdir -p /opt/stalwart && cd /opt/stalwart
sudo nano compose.yaml
# /opt/stalwart/compose.yaml
services:
  stalwart:
    image: stalwartlabs/stalwart:v1.0.3
    container_name: stalwart
    init: true
    restart: unless-stopped
    network_mode: host
    environment:
      STALWART_RECOVERY_MODE_PORT: "18080"
      STALWART_PUBLIC_URL: "https://mail.sedv-lab.de"
      STALWART_HEALTHCHECK_URL: "http://127.0.0.1:18080/healthz/live"
    volumes:
      - ./etc:/etc/stalwart
      - ./data:/var/lib/stalwart

Ohne STALWART_HEALTHCHECK_URL fragt der Healthcheck Port 443 und dann 8080 ab, beide gehören hier Caddy. STALWART_PUBLIC_URL entspricht der Vorgabe aus dem Hostnamen, hält die öffentliche Adresse aber ausdrücklich fest. Legen Sie die Ordner an, kopieren Sie das Zertifikat mit voller Kette hinein und geben Sie alles dem Nutzer 2000:

cd /opt/stalwart
sudo mkdir -p etc/tls data
sudo install -m 0644 /pfad/zu/mail.sedv-lab.de.crt etc/tls/   # mit Zwischenzertifikaten
sudo install -m 0600 /pfad/zu/mail.sedv-lab.de.key etc/tls/
sudo chown -R 2000:2000 etc data
sudo docker compose up -d
sleep 10
sudo docker logs stalwart 2>&1 | sed -n 's/.*password: *//p' | tail -1 | sudo tee .bootstrap-pw >/dev/null
sudo chmod 600 .bootstrap-pw

Statt den Assistenten im Browser durchzuklicken, schicken Sie seine Angaben als ein JSON-Objekt an die API. Das ist wiederholbar und passt zu einem späteren Neuaufbau aus Notizen:

sudo tee bootstrap.json >/dev/null <<'E'
{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"],
 "methodCalls":[["x:Bootstrap/set",{"update":{"singleton":{
   "defaultHostname":"mail.sedv-lab.de","defaultDomain":"sedv-lab.de",
   "requestTlsCertificate":false,"generateDkimKeys":true,
   "tracer":{"@type":"Stdout","ansi":false,"level":"info"}}}},"c1"]]}
E
curl -s -u "admin:$(sudo cat .bootstrap-pw)" -H 'Content-Type: application/json' \
  -X POST http://127.0.0.1:18080/jmap/ -d @bootstrap.json

requestTlsCertificate steht auf false: Caddy belegt 443, die ACME-Prüfung von Stalwart käme dort nie an. Der Tracer Stdout schreibt das Log nach docker logs, die Vorgabe wäre eine Datei im Container.

Verifizieren: Die Antwort enthält "username": "admin@sedv-lab.de" und ein Passwort. Speichern Sie es sofort, es erscheint nur dieses eine Mal.

Schritt 3: Listener, Zertifikat und Resolver im Recovery-Modus

Beim nächsten normalen Start legt Stalwart sieben Standard-Listener an, darunter 443 und 8080, und bekäme Ärger mit Caddy. Legen Sie die Listener deshalb vorher selbst an. Das geht im Recovery-Modus, in dem Stalwart nur seine Verwaltung startet und keine Mail annimmt. Der Administrator aus Schritt 2 bekam dort im Test 403 Forbidden, Sie brauchen einen Recovery-Zugang:

openssl rand -hex 16 | sudo tee .recovery-pw >/dev/null
printf 'STALWART_RECOVERY_MODE=1\nSTALWART_RECOVERY_ADMIN=recovery:%s\n' "$(sudo cat .recovery-pw)" | sudo tee recovery.env >/dev/null
printf 'services:\n  stalwart:\n    env_file: recovery.env\n' | sudo tee compose.recovery.yaml >/dev/null
sudo chmod 600 .recovery-pw recovery.env
sudo docker compose -f compose.yaml -f compose.recovery.yaml up -d

Dann folgen in einer Anfrage die Listener: SMTP auf 25, Versand mit TLS auf 465, IMAP mit TLS auf 993 und HTTP nur auf 127.0.0.1:18080 für Caddy. Das Zertifikat kommt aus der Datei, und HTTP liest die Absenderadresse aus dem Kopf X-Forwarded-For, den Caddy setzt. Speichern Sie die Anfrage mit sudo nano setup.json:

{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"],"methodCalls":[
 ["x:NetworkListener/set",{"create":{
   "l1":{"name":"smtp","protocol":"smtp","bind":{"[::]:25":true}},
   "l2":{"name":"submissions","protocol":"smtp","bind":{"[::]:465":true},"implicitTls":true},
   "l3":{"name":"imaps","protocol":"imap","bind":{"[::]:993":true},"implicitTls":true},
   "l4":{"name":"http","protocol":"http","bind":{"127.0.0.1:18080":true},"implicitTls":false,"useTls":false}}},"a"],
 ["x:Certificate/set",{"create":{"c1":{
   "certificate":{"@type":"File","filePath":"/etc/stalwart/tls/mail.sedv-lab.de.crt"},
   "privateKey":{"@type":"File","filePath":"/etc/stalwart/tls/mail.sedv-lab.de.key"}}}},"b"],
 ["x:Http/set",{"update":{"singleton":{"clientIpSource":"xForwardedFor"}}},"c"],
 ["x:DnsResolver/set",{"update":{"singleton":{"@type":"Custom","servers":{
   "0":{"protocol":"udp","address":"127.0.0.1","port":53},
   "1":{"protocol":"tcp","address":"127.0.0.1","port":53}}}}},"d"]]}
curl -s -u "recovery:$(sudo cat .recovery-pw)" -H 'Content-Type: application/json' \
  -X POST http://127.0.0.1:18080/jmap/ -d @setup.json

Als Antwort kommt für jedes Objekt created oder updated. Den Resolver nahm Stalwart nur in dieser Form mit Schlüsseln "0" und "1" an, eine JSON-Liste lehnte es mit invalidPatch ab. Die zweite Zeile beim Resolver ist kein Zufall. Im Test stand dort zuerst nur UDP, und Stalwart meldete beim Start The configured DNS resolver cannot validate DNSSEC. DANE has been disabled to avoid deferring mail. Unbound prüfte DNSSEC einwandfrei, aber Stalwart verlangt bei einem eigenen Resolver mindestens einen Server mit TCP oder TLS. Erst danach blieb die Warnung weg. Ohne diesen Eintrag prüft Stalwart beim Versand keine TLSA-Einträge der Empfänger.

Für Gegenstellen, die keinen Servernamen schicken, machen Sie das Zertifikat zum Standard. Die ID steht in der Antwort unter created:

{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"],"methodCalls":[
 ["x:SystemSettings/set",{"update":{"singleton":{"defaultCertificateId":"<ID>"}}},"a"]]}

Danach entfernen Sie den Recovery-Zugang und die Einrichtungsdateien und starten normal:

sudo rm recovery.env compose.recovery.yaml .recovery-pw .bootstrap-pw bootstrap.json setup.json
sudo docker compose up -d --force-recreate

Lief vorher ein anderer Mailserver auf Port 25, stoppen Sie ihn unmittelbar davor, etwa mit sudo systemctl disable --now postfix. Im Test lagen zwischen dem Stopp von Postfix und dem SMTP-Listener von Stalwart 3 Sekunden.

Verifizieren: sudo ss -ltnp | grep stalwart zeigt 25, 465, 993 und 127.0.0.1:18080, im Log erscheint kein Hinweis auf abgeschaltetes DANE.

Schritt 4: Weboberfläche hinter Caddy

Die Weboberfläche nimmt Anmeldungen nur über HTTPS unter dem Hostnamen an. Caddy holt dafür ein eigenes Zertifikat und reicht weiter:

mail.sedv-lab.de {
	import sicher
	crowdsec
	reverse_proxy 127.0.0.1:18080
}

Der Block nutzt die Header aus Teil 4 und den CrowdSec-Bouncer aus Teil 17. Weil HTTP jetzt X-Forwarded-For vertraut, darf der HTTP-Listener nur auf 127.0.0.1 lauschen, sonst könnte jeder eine falsche Adresse vorgeben. Dass die echte Adresse ankommt, zeigte sich schnell: Wenige Minuten nach dem Start sperrte Stalwart einen Scanner, der über Caddy kam, mit security.ip-blocked und dessen eigener IP.

Verifizieren: curl -s -u admin@sedv-lab.de https://mail.sedv-lab.de/jmap/session fragt das Passwort ab und liefert "username":"admin@sedv-lab.de" mit "apiUrl":"https://mail.sedv-lab.de/jmap/". Die Weboberfläche liegt unter https://mail.sedv-lab.de/admin.

Schritt 5: DNS-Einträge

Stalwart erzeugt beim ersten Start DKIM-Schlüssel mit Ed25519 und RSA und zeigt alle Einträge unter Domains, View Zone File. Übernehmen Sie nur, was Sie nutzen. MX und A für mail standen hier schon:

mail                            AAAA  2a03:4000:15:16ab:8f9:8bff:fe7a:5d94
@                               TXT   "v=spf1 mx -all"
mail                            TXT   "v=spf1 a -all"
v1-ed25519-20261105._domainkey  TXT   "v=DKIM1; k=ed25519; h=sha256; p=..."
v1-rsa-20261105._domainkey      TXT   "v=DKIM1; k=rsa; h=sha256; p=..."
_dmarc                          TXT   "v=DMARC1; p=none; rua=mailto:postmaster@sedv-lab.de"
_smtp._tls                      TXT   "v=TLSRPTv1; rua=mailto:postmaster@sedv-lab.de"

Der AAAA-Eintrag für mail fehlte hier zuerst, obwohl das Reverse-DNS der IPv6 schon auf mail.sedv-lab.de zeigte. Ohne ihn scheitert die Gegenprobe, sobald der Server über IPv6 sendet. Nehmen Sie im AAAA die Adresse, die der Server ausgehend nutzt, ip -6 route get 2a00:1450:4001:80b::200e zeigt sie als src. Der RSA-Schlüssel ist länger als 255 Zeichen und muss in mehrere Zeichenketten geteilt werden, die meisten DNS-Oberflächen machen das selbst.

DMARC steht bewusst auf p=none. So kommen Berichte, ohne dass eine Fehlkonfiguration eigene Mails kostet. Teil 12 wertet die Berichte aus und verschärft die Regel. Die TLS-Berichte kommen an dieselbe Adresse, legen Sie dafür ein Postfach oder einen Alias postmaster an. Die von Stalwart vorgeschlagenen TLSA-Einträge 2 1 1 für die Zwischenzertifikate brauchen Sie nicht, wenn wie hier schon ein 3 1 1 für den eigenen Schlüssel steht.

Verifizieren: dig +short TXT _dmarc.sedv-lab.de @1.1.1.1 liefert den Eintrag. Bei netcup CloudDNS war er nach rund 2 Minuten bei 1.1.1.1 sichtbar.

Schritt 6: Von außen messen

Die erste Testmail an mail-tester.com lieferte ein Skript über 465 bei Stalwart ein, eine Sekunde später hatte Stalwart sie über Port 25 zugestellt. Das Ergebnis: 3,8 von 10. SPF, DKIM mit beiden Signaturen und DMARC bestanden, abgezogen wurden 3,6 Punkte für RCVD_IN_PBL und 2,9 Punkte, weil die von Hand gebaute Testmail keine Message-ID hatte. Mailprogramme setzen sie selbst. Mit Message-ID kam die zweite Mail auf 10 von 10. Der PBL-Abzug fehlte dabei ebenfalls, obwohl die Adresse zu diesem Zeitpunkt noch gelistet war.

mail-tester.com: Ergebnis 10 von 10, SpamAssassin 0,2, gültige DKIM- und SPF-Prüfung
mail-tester.com: 10 von 10, SpamAssassin 0,2, DKIM und SPF gültig

internet.nl prüft den Empfang: DNSSEC, SPF, DKIM, DMARC, STARTTLS und DANE. Der erste Lauf ergab 74 Prozent mit dem Hinweis, der Mailserver sei nicht erreichbar. Im Log von Stalwart stand die Ursache:

TLS handshake ... remoteIp = 62.204.66.10 ... version = "TLSv1_2"
Rate limit exceeded (smtp.rate-limit-exceeded) ... remoteIp = 62.204.66.10,
  name = "MtaInboundThrottle with id jnbxuu2uaaqb", limit = 5, period = 1000ms

Die Vorgabe lässt je Absender-IP nur 5 Verbindungen pro Sekunde zu. internet.nl öffnet für seine TLS-Prüfungen mehr. Die Drossel ist sinnvoll, im Test wurde sie auf 20 angehoben:

{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"],"methodCalls":[
 ["x:MtaInboundThrottle/set",{"update":{"<ID>":{"rate":{"count":20,"period":1000}}}},"a"],
 ["x:Action/set",{"create":{"r":{"@type":"ReloadSettings"}}},"b"]]}

Die ID liefert x:MtaInboundThrottle/get, gemeint ist der Eintrag „Sender IP throttle“. Für einen kleinen Server mit wenigen Absendern sind auch 20 unkritisch, wer sparsam bleiben will, setzt nach dem Test 5 zurück. Der zweite Lauf ergab 96 Prozent. Dazu trug auch bei, dass die IPv6-Erreichbarkeit der netcup-Nameserver im ersten Lauf durchfiel und im zweiten nicht mehr. STARTTLS, Zertifikatskette, CAA und DANE bestanden alle Prüfungen, nur die DMARC-Regel p=none kostet Punkte.

internet.nl: Mailtest für sedv-lab.de mit 96 Prozent, nur DMARC noch rot
internet.nl: 96 Prozent, nur DMARC mit p=none noch rot

Den Empfang von großen Anbietern zeigt das Log. Eine Mail aus Microsoft 365 und eine von outlook.de kamen über IPv6 aus dem Netz von Microsoft, beide mit TLS 1.3, SPF und DKIM bestanden, beide als Ham eingestuft. Dank Host-Netz stehen im Log die echten Adressen der Microsoft-Server. Zwischen dem Befehl MAIL FROM und der Annahme in die Warteschlange lagen 10 und 15 Sekunden.

Verifizieren: sudo docker logs stalwart 2>&1 | grep -E 'dmarc-pass|message-ingest' zeigt eingehende Mails mit Ergebnis.

Die Sperrliste, die nichts mit Spam zu tun hat

Der Versand an outlook.de scheiterte sofort:

550 5.7.1 Service unavailable, Client host [62.83.99.6] blocked using Spamhaus.
To request removal from this list see https://www.spamhaus.org/query/ip/62.83.99.6 (AS3130).

Die Adresse stand in der Policy Block List (PBL) von Spamhaus. Die Liste sagt nicht, dass jemand gespammt hat, sondern dass von diesem Adressbereich keine Mail direkt verschickt werden soll. Neue VPS-Adressen landen oft dort. Eine Abfrage über den eigenen Resolver zeigt den Eintrag. Öffentlichen Resolvern wie 1.1.1.1 antwortet Spamhaus nur mit einem Ablehnungscode:

dig +short 6.99.83.62.zen.spamhaus.org @127.0.0.1
dig +short TXT 6.99.83.62.zen.spamhaus.org @127.0.0.1

Die Antwort 127.0.0.11 bedeutet einen Eintrag, den Spamhaus selbst pflegt. Den tragen Sie unter check.spamhaus.org selbst aus: Eintrag öffnen, Entfernung anfordern, Bestätigungscode aus der Mail eingeben. Bei 127.0.0.10 pflegt der Anbieter den Bereich, dann muss er die Freigabe veranlassen. Im Test meldete die Austragung „Removal successful“, und spätestens 14 Minuten später listete der Nameserver von Spamhaus die Adresse nicht mehr. Microsoft lehnte unmittelbar danach trotzdem weiter ab, dort gilt offenbar ein eigener, zwischengespeicherter Stand. Der nächste Versuch eine halbe Stunde später, 44 Minuten nach der Austragung, ging mit 250 durch.

Typische Fehler

MeldungUrsache und Lösung
The configured DNS resolver cannot validate DNSSEC. DANE has been disabledEigener Resolver nur mit UDP. Einen zweiten Eintrag mit "protocol":"tcp" ergänzen.
Rate limit exceeded ... MtaInboundThrottle ... limit = 5Testdienst öffnet viele Verbindungen. Drossel für den Test anheben.
550 5.7.1 ... blocked using SpamhausIP in der PBL. Bei 127.0.0.11 selbst austragen, danach Zeit lassen.
403 Forbidden im Recovery-ModusDer Administrator aus der Einrichtung bekam im Test keinen Zugriff. STALWART_RECOVERY_ADMIN setzen.
invalidPatch beim ResolverServer als JSON-Liste angegeben. Als Objekt mit Schlüsseln "0", "1" schreiben.

Häufige Fragen

Warum nicht Stalwart das Zertifikat selbst holen lassen?

Die Prüfung von Let's Encrypt über TLS-ALPN braucht Port 443, den Caddy belegt. Wichtiger noch: Ein neues Zertifikat bringt in der Regel einen neuen Schlüssel, und der TLSA-Eintrag passt dann nicht mehr. Sender mit DANE stellen dann nicht zu, sondern halten die Mail zurück. Wer den Schlüssel wechselt, muss den TLSA-Eintrag vorher mitziehen.

Was passiert bei der Erneuerung des Zertifikats?

Stalwart liest die Dateien nur beim Laden der Zertifikate. Kopieren Sie nach jeder Erneuerung Zertifikat und Schlüssel neu nach etc/tls und lösen Sie die Aktion ReloadTlsCertificates aus, so wie ReloadSettings oben. Damit der TLSA-Eintrag gültig bleibt, muss das Werkzeug zur Erneuerung den Schlüssel behalten, bei lego mit --reuse-key.

Braucht es Port 587 und 143?

Nein. Port 465 und 993 verschlüsseln von Anfang an, das empfiehlt RFC 8314 für Mailprogramme. Jeder weitere offene Port ist nur zusätzliche Angriffsfläche.

Wie sichere ich Stalwart?

Die Daten liegen in RocksDB unter data. Für eine stimmige Kopie stoppen Sie den Container kurz oder sichern einen Snapshot, siehe Teil 19.

Fazit

Technisch war der Server nach rund einer Dreiviertelstunde sauber: 10 von 10 bei mail-tester, 96 Prozent bei internet.nl, DANE beim Empfang gültig, Mails von Microsoft kamen über IPv6 und TLS 1.3 an. Gebremst haben zwei Dinge, die keine Testumgebung zeigt: eine Drossel, die einen externen Test aussperrt, und eine Sperrliste, die eine neue Adresse pauschal blockiert. Planen Sie die Austragung aus der PBL vor dem ersten echten Versand ein.

Weiterführende Anleitungen und Quellen

Server im NetzStalwartMailserverDKIMDMARCDANESpamhaus