listmonk: der eigene Newsletter-Server – DSGVO-freundlich
listmonk 6.2 mit Postgres 18 hinter Traefik: Liste mit Double Opt-in, erste Kampagne, Datenschutz-Schalter – und ein Testlauf, bevor echte Mails rausgehen.
Inhaltsverzeichnis
Newsletter selbst verschicken heißt: Die Adressliste liegt bei dir, nicht bei einem Dienstleister, der sie „zur Verbesserung des Angebots" auswertet. listmonk macht das erstaunlich bequem – der schwierige Teil ist nicht die Software, sondern dass die Mails auch ankommen.
Was bauen wir?
Wir betreiben listmonk 6.2.0 mit PostgreSQL 18.6 hinter Traefik. Am Ende hast du eine Empfängerliste mit Double Opt-in, eine fertig verschickte Kampagne und die Datenschutzschalter so gesetzt, dass Abonnenten ihre Daten selbst exportieren und löschen können.
Dazwischen liegt ein Schritt, den die meisten Anleitungen auslassen: Wir schicken die erste Kampagne an einen lokalen Auffang-Server, bevor eine einzige Mail nach draußen geht. Ein Newsletter, der beim ersten Versuch in den Spamordner rutscht, kostet dich Reputation, die du mühsam zurückgewinnen musst.
Die Software ist der leichte Teil
Voraussetzungen
- Traefik als Reverse Proxy mit funktionierendem Let’s-Encrypt-Resolver und einer Domain
- Ein Mailserver oder SMTP-Zugang, dessen Absenderdomain per SPF, DKIM und DMARC abgesichert ist
- Rund 500 MB Arbeitsspeicher für Anwendung und Datenbank
VPS 1000 G12.5
4 vCore · 8 GB RAM · 128 GB SSD
ab 14,50 €/Monat
listmonk ist sparsam – für ein paar tausend Adressen reicht der kleine VPS.
💶 1 Monat gratis:
Einmal einlösbar, gilt für VPS 1000 G12.5. Im Warenkorb einlösen →
💶 5,95 € Gutschein für netcup-Neukunden: immer gültig · nicht für Domains und VPS Lite
Schritt für Schritt
Schritt 1: Der Stack
mkdir -p /opt/listmonk/uploads && cd /opt/listmonkZugangsdaten zuerst. Benutzername und Passwort des Administrators werden beim allerersten Start aus diesen Variablen angelegt – danach ignoriert listmonk sie:
umask 077
{
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d '=+/' | cut -c1-24)"
echo "LISTMONK_ADMIN_USER=DEIN_BENUTZERNAME"
echo "LISTMONK_ADMIN_PASSWORD=DEIN_STARKES_PASSWORT"
} > .env# /opt/listmonk/compose.yaml
services:
db:
image: postgres:18.6-alpine
container_name: listmonk-db
restart: unless-stopped
environment:
POSTGRES_USER: listmonk
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: listmonk
volumes:
- pgdata:/var/lib/postgresql
networks: [listmonk-intern]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U listmonk"]
interval: 10s
timeout: 5s
retries: 6
app:
image: listmonk/listmonk:v6.2.0
container_name: listmonk
restart: unless-stopped
depends_on:
db:
condition: service_healthy
command: ["sh", "-c", "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"]
environment:
LISTMONK_app__address: 0.0.0.0:9000
LISTMONK_db__host: db
LISTMONK_db__port: 5432
LISTMONK_db__user: listmonk
LISTMONK_db__password: ${POSTGRES_PASSWORD}
LISTMONK_db__database: listmonk
LISTMONK_db__ssl_mode: disable
LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USER}
LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD}
TZ: Europe/Berlin
volumes:
- ./uploads:/listmonk/uploads:rw
networks: [listmonk-intern, proxy]
labels:
- traefik.enable=true
- traefik.http.routers.listmonk.rule=Host(`DEINE_DOMAIN`)
- traefik.http.routers.listmonk.entrypoints=websecure
- traefik.http.routers.listmonk.tls.certresolver=le
- traefik.http.services.listmonk.loadbalancer.server.port=9000
volumes:
pgdata:
networks:
listmonk-intern:
internal: true
proxy:
external: trueDie command-Zeile sieht umständlich aus, macht aber genau drei sinnvolle Dinge nacheinander:
--install --idempotent legt das Datenbankschema an, falls es fehlt, und tut sonst nichts;
--upgrade fährt bei einem neuen Image die fälligen Migrationen; danach startet der eigentliche
Dienst. Das --config '' ist kein Tippfehler, sondern die Anweisung „benutze keine
Konfigurationsdatei, sondern nur die Umgebungsvariablen".
Die Datenbank liegt in einem eigenen Netz mit internal: true – sie ist damit weder von außen noch
von anderen Containern im proxy-Netz erreichbar.
PostgreSQL 18: Der Mount hat sich geändert
/var/lib/postgresql/**data** ein. Mit PostgreSQL 18
verweigert der Container dann den Start – auch bei leerem Volume, weil die Prüfung am Mount hängt
und nicht am Inhalt. Der Mount gehört eine Ebene höher, auf /var/lib/postgresql.docker compose up -d
docker compose logs app | grep -E "superadmin|setup complete|upgrade|server started"install.go:93: creating superadmin user 'DEIN_BENUTZERNAME'
install.go:116: setup complete
main.go:165: running upgrade...
upgrade.go:74: no upgrades to run. Database is up to date.
⇨ http server started on [::]:9000Die drei Stufen aus der command-Zeile sind gut zu erkennen: Installation, Upgrade-Prüfung,
Dienststart.
Schritt 2: Anmelden, Adresse eintragen, Beispieldaten wegräumen
Unter https://DEINE_DOMAIN/admin meldest du dich mit den Zugangsdaten aus der .env an.

Als Erstes gehst du auf Settings → General und trägst zwei Dinge ein:
- Root URL –
https://DEINE_DOMAIN, ohne Schrägstrich am Ende. Ab Werk steht dorthttp://localhost:9000, und aus diesem Wert baut listmonk jeden Link in jeder Mail: den Bestätigungslink des Double Opt-ins, den Abmeldelink, „View in browser". Bleibt der Standard stehen, kommen bei deinen Empfängern Links an, die ins Leere führen – und niemand kann seine Anmeldung bestätigen. - Default
fromemail – der Absender, etwaKochbrief <kochbrief@deine-domain.de>. Die Adresse muss zu der Domain passen, für die SPF, DKIM und DMARC eingerichtet sind.
Speichern, listmonk lädt sich danach kurz neu.
listmonk legt beim ersten Start außerdem Beispieldaten an: zwei Listen („Default list" und
„Opt-in list"), zwei erfundene Abonnenten (john@example.com, anon@example.com) und eine
Kampagne namens „Test campaign". Räum das weg, bevor du echte Adressen einträgst – sonst stehen die
Beispieladressen irgendwann in deiner ersten echten Kampagne. Die Beispielabonnenten löschst du unter
Subscribers, die Listen unter Lists, die Kampagne unter Campaigns.
Schritt 3: SMTP – erst auffangen, dann verschicken
Unter Settings → SMTP trägst du den Mailserver ein. Bevor du dort deine echten Zugangsdaten hinterlegst, lohnt ein Zwischenschritt: ein lokaler Auffang-Server, der alle Mails annimmt und keine weiterleitet. So siehst du, was listmonk tatsächlich verschickt, ohne Reputation zu verbrennen.
Ergänze dafür vorübergehend einen Container im Stack:
mailpit:
image: axllent/mailpit:v1.33.0
container_name: listmonk-mailpit
restart: unless-stopped
networks: [listmonk-intern]In den SMTP-Einstellungen zeigst du dann auf mailpit, Port 1025, Auth protocol: None und
TLS: Off:

Für den echten Betrieb trägst du danach den Hostnamen deines Mailservers ein, Port 587, Auth protocol: PLAIN oder LOGIN, deine Zugangsdaten und TLS: STARTTLS. Die Verknüpfungen über den Feldern (Gmail, Amazon SES, Mailgun …) füllen die Felder für bekannte Anbieter vor.
Skip TLS verification bleibt aus
Schritt 4: Eine Liste mit Double Opt-in
Unter Lists → New legst du die Empfängerliste an. Zwei Einstellungen entscheiden über die rechtliche Seite:
- Type: Private – die Liste taucht nicht in öffentlichen Anmeldeformularen auf, solange du das nicht willst. „Public" brauchst du für ein Anmeldeformular auf der eigenen Website.
- Opt-in: Double – nach der Anmeldung geht eine Bestätigungsmail raus, und erst der Klick darin macht aus dem Eintrag einen bestätigten Abonnenten.
Double Opt-in ist in Deutschland faktisch Pflicht. Es ist zugleich dein Nachweis: listmonk speichert, wann jemand bestätigt hat. Ohne diesen Nachweis stehst du bei einer Beschwerde ohne Beleg da.
Schritt 5: Die erste Kampagne
Unter Campaigns → New legst du Name, Betreff, Liste und Format fest. Als Inhaltstyp bietet listmonk unter anderem einen Rich-Text-Editor und Markdown an – letzteres ist für einen schlichten Textnewsletter oft die bessere Wahl, weil weniger HTML weniger Spam-Verdacht bedeutet.
Im Text arbeitest du mit Platzhaltern:
# Ausgabe 1
Hallo {{ .Subscriber.FirstName }},
diese Woche: Kürbissuppe, Zwiebelkuchen und ein Glas Federweißer.{{ .Subscriber.FirstName }} wird beim Versand pro Empfänger ersetzt – eine Abonnentin namens
„Leserin" bekommt „Hallo Leserin,".
Einen Abmeldelink musst du hier nicht einbauen: Die Vorlage, in die listmonk den Text steckt
(„Default campaign template", unter Campaigns → Templates), setzt in den Fuß jeder Mail
„Unsubscribe" und „View in browser". Der individuelle Link dahinter heißt {{ UnsubscribeURL }},
und der gehört in jede Mail.
Verlass dich dabei nicht auf die Software: Baust du dir eine eigene Vorlage und vergisst den Link,
speichert und verschickt listmonk sie trotzdem anstandslos. Der List-Unsubscribe-Header aus den
Datenschutz-Einstellungen geht zwar mit, ersetzt den sichtbaren Abmeldelink im Text aber nicht.
Schreib {{ UnsubscribeURL }} deshalb in jede eigene Vorlage, dann kannst du ihn nicht vergessen.
Ein Klick auf Start verschickt die Kampagne. Danach steht in der Übersicht, was passiert ist:

Jetzt schaust du in den Auffang-Server: Stimmen Absender, Betreff samt Umlauten und der aufgelöste
Platzhalter? Zeigen die Links im Fuß auf https://DEINE_DOMAIN/… und nicht auf localhost? Erst
wenn das stimmt, stellst du SMTP auf den echten Mailserver um. Die Mails liegen in Mailpit, das nur
im internen Netz hängt; abrufen kannst du sie über seine API, zum Beispiel die zuletzt eingegangene:
docker compose exec app wget -qO- http://mailpit:8025/api/v1/message/latestDanach nimmst du den mailpit-Block wieder aus der Compose-Datei und startest den Stack mit
docker compose up -d --remove-orphans neu.

Schritt 6: Die Datenschutz-Schalter
Unter Settings → Privacy steht das, was den Unterschied zwischen „selbst gehostet" und „datenschutzfreundlich" ausmacht. Jeder Schalter ist eine bewusste Entscheidung:
- Disable tracking – schaltet die Zählung von Öffnungen und Klicks komplett ab. Ein Newsletter ohne Zählpixel braucht dafür keine Einwilligung und keinen Absatz in der Datenschutzerklärung.
- Individual subscriber tracking – koppelt Öffnungen und Klicks an die einzelne Person. Ab Werk ist das aus: Dann zählt listmonk weiter, aber nur in der Summe. Das ist der brauchbare Mittelweg, wenn du wissen willst, ob eine Ausgabe ankommt.
- Include
List-Unsubscribeheader – lass das an. Mailprogramme zeigen damit einen Abmeldeknopf direkt an, und große Anbieter werten das positiv. - Allow exporting und Allow wiping – Abonnenten können ihre gespeicherten Daten selbst abrufen und sich vollständig löschen. Das sind Auskunfts- und Löschanspruch, ohne dass du eine Mail beantworten musst. Beides ist ab Werk an.
- Record opt-in IP address – speichert zusätzlich die IP-Adresse, von der aus jemand bestätigt hat. Das stärkt den Nachweis, ist aber ein weiteres personenbezogenes Datum; ab Werk aus.
Meine Empfehlung: Tracking pro Person aus, List-Unsubscribe an, Export und Löschung an – das ist
zugleich der Auslieferungszustand, du musst also nur nichts verstellen. Damit
beantwortest du die häufigsten Anfragen, bevor sie gestellt werden.
Wenn es nicht funktioniert
Der Container startet immer neu und meldet error loading flags: flag needs an argument: --config. Beim Kopieren der command-Zeile sind die beiden einfachen Anführungszeichen hinter --config verlorengegangen – das passiert leicht, wenn man den Befehl durch mehrere Shell-Ebenen schickt. Prüf mit docker inspect listmonk --format '{{json .Config.Cmd}}', was tatsächlich ankommt: Dort muss hinter jedem --config ein leeres Argument stehen. Schreib die Compose-Datei im Zweifel mit einem Editor direkt auf dem Server statt sie über verschachtelte Anführungszeichen zu erzeugen.
listmonk startet nicht mehr und meldet error reading SMTP config: decoding failed. In den gespeicherten Einstellungen steht ein ungültiger Wert – etwa eine Zeitangabe ohne Einheit. Das Tückische: Die Weboberfläche ist dann nicht erreichbar, du kannst es also nicht dort korrigieren. Die Einstellungen liegen in der Tabelle settings; mit docker compose exec db psql -U listmonk -d listmonk kommst du heran und kannst den Schlüssel smtp per UPDATE wieder auf einen gültigen Block setzen. Danach docker compose restart app.
Der Datenbank-Container startet nicht und redet von pg_ctlcluster. Das ist die PostgreSQL-18-Umstellung: Der Mount darf nicht mehr auf /var/lib/postgresql/data zeigen, sondern muss auf /var/lib/postgresql. Bei einer bestehenden Datenbank reicht das Verschieben nicht – dort brauchst du pg_dump mit der alten Hauptversion und einen Import in die neue.
Die Kampagne bleibt auf running stehen und es wird nichts versendet. Dann erreicht listmonk den SMTP-Server nicht. docker compose logs app | grep -i smtp zeigt den Grund; häufig ist es ein falscher Port (587 statt 465 oder umgekehrt), eine falsche TLS-Einstellung oder ein Provider, der Port 25 nach außen sperrt. Zum Eingrenzen hilft der lokale Auffang-Server aus Schritt 3: Funktioniert der Versand dorthin, liegt es nicht an listmonk.
Die Mails kommen an, landen aber im Spam. Das ist kein listmonk-Problem. Prüf, ob SPF, DKIM und DMARC für die Absenderdomain stimmen und ob die Absenderadresse zur Domain passt, über die du versendest – wie das geht, steht in Mailserver-Zustellbarkeit. Vermeide außerdem reines Bild-HTML und schick beim ersten Mal an eine kleine Liste, statt zehntausend Adressen auf einmal anzuschreiben.
Dein Problem ist nicht dabei? Alle Fehlermeldungen durchsuchen →
Wartung & Backups
Updates sind ein Tag-Wechsel; die Migration erledigt die --upgrade-Stufe im command beim
Start automatisch:
cd /opt/listmonk
docker compose exec -T db pg_dump -U listmonk listmonk > vor-update-$(date +%F).sql
sed -i 's/listmonk:v6.2.0/listmonk:vNEUE_VERSION/' compose.yaml
docker compose pull && docker compose up -d
docker compose logs app | grep -i upgradeDer Dump vorher ist Pflicht: Wenn eine Migration schiefgeht, liegt deine komplette Empfängerliste darin. Und die PostgreSQL-Hauptversion nicht gleichzeitig anfassen – dafür ist immer Dump und Import fällig.
Sichern musst du zwei Dinge, plus die Zugangsdaten:
pg_dump-Ausgabe # Abonnenten, Listen, Kampagnen, Opt-in-Nachweise
/opt/listmonk/uploads/ # Bilder aus dem Medienbereich
/opt/listmonk/.env # DatenbankpasswortDer Dump ist hier besonders wichtig, weil darin die Bestätigungszeitpunkte der Double-Opt-ins stehen. Verlierst du die, hast du im Streitfall keinen Nachweis mehr, dass jemand zugestimmt hat. Täglich per systemd-Timer und ab ins Restic-Backup.
Laufende Pflege gibt es kaum, aber zwei Gewohnheiten lohnen sich: Nach jeder Kampagne einmal auf die Bounce-Zahl schauen und harte Bounces aus der Liste nehmen – tote Adressen kosten Reputation. Und die Liste nicht künstlich am Leben halten: Wer sich abmeldet, wird gelöscht, nicht „für später" aufbewahrt.
Realistischer Aufwand: Das Aufsetzen kostet einen Abend, der laufende Betrieb ein paar Minuten pro Ausgabe. Der Teil, der wirklich Aufmerksamkeit braucht, ist die Zustellbarkeit – und die entscheidet sich vor dem ersten Versand, nicht danach.
Feedback per E-Mail: feedback@serverkueche.de
Das könnte dir auch schmecken


