Installation mit Docker Compose
Der unterstützte Weg, von Anfang bis Ende: was du brauchst, was docker compose up tatsächlich tut,
wie der erste Administrator angelegt wird und wofür jeder einzelne Container da ist.
Alles Folgende geht von der unveränderten compose.yaml aus dem Repository aus, die das
veröffentlichte Image zieht. Ein Build aus dem Quelltext ist ein Entwicklungs-Setup und gehört nach
CONTRIBUTING.
Was du brauchst
- Eine Maschine mit Docker und Docker Compose.
- Sonst nichts. Vor dem ersten Start ist keine Konfiguration auszufüllen.
Images werden für linux/amd64 und linux/arm64 veröffentlicht, jeweils auf einem nativen Runner
gebaut und zu einem einzigen Manifest zusammengeführt. Ein Mac mit Apple Silicon, ein ARM-NAS oder
ein 64-Bit-Raspberry-Pi führt plMail also ohne Emulation aus. Je nach Maschine unterscheiden sich
einige Details — siehe Plattform-Hinweise.
Über die Dimensionierung lohnt es sich nachzudenken, eine feste Regel gibt es nicht. PHPs
memory_limit steht im Image auf 2G, jeder der vier Messenger-Worker ist durch
--memory-limit=256M begrenzt und startet sich bei --time-limit=3600 neu, und Postgres, der
Mercure-Hub und der IMAP-Supervisor wollen jeweils ihren eigenen Anteil obendrauf. Die Worker, der
Hub und der IMAP-Supervisor teilen sich einen Container, ein Speicherlimit darauf gilt also für alle
zusammen. Der erste
Abgleich eines großen Postfachs ist die anstrengendste Phase überhaupt.
Das genügt für IMAP-Postfächer. Gmail und Outlook brauchen zusätzlich OAuth-Zugangsdaten — siehe Google und Microsoft.
Der typische Fehlerfall ist, einen Mailserver zu erwarten. plMail ist ein Client. Es empfängt keine Mail aus dem Internet, hostet deine Domain nicht und braucht nirgends Port 25.
Beschaffen
git clone https://github.com/karatektus/pl_mail.git
cd pl_mail
Der Clone dient allein der compose.yaml — der Anwendungscode liegt im Image. Wenn du lieber nicht
klonen möchtest, genügt es, nur die compose.yaml zu kopieren, sofern du daneben eine .env
anlegst oder jede Vorgabe aus der Konfigurationsreferenz akzeptierst.
Häng -f compose.dev.yaml nicht an, außer du entwickelst. Es schaltet jeden Dienst vom
veröffentlichten Image auf ein lokal gebautes Entwicklungs-Image mit eingehängtem Quelltext um.
Starten
docker compose up -d
Öffne dann https://localhost.
Auf einer Maschine ohne eigenen Browser öffnest du von einer anderen aus https:// und die Adresse
oder den Hostnamen dieser Maschine — etwa https://192.168.1.20. In beiden Fällen hat der Container
das Zertifikat selbst signiert; der Browser warnt also einmal und lässt dich weitermachen. Für ein
Zertifikat, dem Browser vertrauen, setz SERVER_NAME auf eine Domain, die auf diese Maschine zeigt,
oder stell einen Reverse-Proxy davor.
Schließe die Einrichtung ab, bevor du irgendetwas anderes machst. Solange es noch keinen Nutzer
gibt, steht /install jedem offen, der die Instanz erreicht — genau so legst du dein eigenes Konto
an, und genau so wird jemand anderes zum Administrator, wenn er schneller da ist. Das ist der
übliche Handel bei selbst gehosteter Software, und das Fenster ist real: die README empfiehlt einen
Reverse-Proxy davor, und TrueNAS gibt Port 30080 nach außen. Leg das Konto an, dann ist das
Fenster zu.
Der erste Start dauert länger als spätere, und er führt eine bestimmte Abfolge von Schritten aus, die du kennen solltest:
secrets-initläuft und beendet sich. Es erzeugtAPP_SECRET,APP_ENCRYPTION_KEY,POSTGRES_PASSWORDundMERCURE_JWT_SECRETinvar/secrets/generated.envauf dem gemeinsamen Volumeapp_secrets, dazu eine nackte Dateipostgres_password. Es läuft vor allem anderen, weil Postgres sein Passwort beim Anlegen des Containers liest und nicht darauf warten kann, dass die Anwendung es ihm reicht. Bei jedem späteren Start findet es die Datei vor und tut nichts.databasestartet und liest sein Passwort überPOSTGRES_PASSWORD_FILE. Jeder Anwendungsdienst wartet auf dessen Healthcheck, der eine Startphase von 60 Sekunden zugesteht.- Jeder Anwendungscontainer führt denselben Entrypoint aus. Er lädt die erzeugten Geheimnisse,
setzt
DATABASE_URLausPOSTGRES_PASSWORDzusammen — es sei denn, du hast eine DSN mit einem eigenen Passwort mitgegeben —, wartet bis zu 60 Versuche lang auf die Datenbank und führt dannapp:db:migrateaus: Doctrines Migrate unter einem Postgres-Advisory-Lock, damit die gemeinsam startenden Container nicht kollidieren. app:secrets:initläuft, nach den Migrationen. Es prüft, ob der geltende Verschlüsselungsschlüssel die bereits gespeicherten Zugangsdaten entschlüsseln kann, und erzeugt anschließend ein VAPID-Schlüsselpaar sowie das JMAP-JWT-Schlüsselpaar, falls diese fehlen.- Caddy nimmt den Betrieb auf. Der
php-Container veröffentlicht 80/tcp, 443/tcp und 443/udp — umlegbar überHTTP_PORT,HTTPS_PORTundHTTP3_PORT— und bedient die Namen ausSERVER_NAME, dascompose.yamlauflocalhost, :443, php:80setzt:localhostist der Name, für den das selbstsignierte Zertifikat ausgestellt wird, und:443beantwortet jeden anderen Namen und jede andere Adresse mit demselben Zertifikat. Außerdem leitet er/.well-known/mercure*an den Hub-Container weiter, sodass der Browser den Hub auf derselben Origin erreicht und kein CORS nötig ist.
docker compose up -d --wait blockiert, bis die Healthchecks bestehen — in einem Skript die bessere
Form.
Der typische Fehlerfall ist, die Logs des falschen Containers zu lesen. Sechs Dienste laufen mit demselben Image, und alle sechs migrieren beim Start; die, die das Rennen um den Advisory-Lock verloren haben, geben "another container is running migrations" aus — sie sind nicht die, die du untersuchen willst.
Den ersten Administrator anlegen
Öffne die Anwendung, dann bietet sie an, einen anzulegen. Die Seite /install fragt nach Namen,
Adresse, Passwort und der öffentlichen URL, unter der plMail erreichbar sein wird — vorbelegt mit
der Adresse, unter der du die Seite geöffnet hast, was immer dann richtig ist, wenn du sie so
erreichst wie später alle anderen auch.
Dasselbe vom Terminal aus:
docker compose exec php php bin/console app:setup
/install ist unauthentifiziert, weil es niemanden gibt, den man authentifizieren könnte. Geschützt
ist die Seite durch genau eine Bedingung — die Installation hat keine Benutzer —, geprüft beim
Rendern der Seite, beim Absenden des Formulars und ein drittes Mal innerhalb des gesperrten
Schreibvorgangs. In dem Moment, in dem ein Benutzer existiert, antwortet die Seite dauerhaft mit
404.
Die öffentliche URL, die du angibst, wird nach var/secrets/generated.env geschrieben und nicht in
eine Konfigurationsdatei, weil ein dauerhaft laufender Worker, der ein Push-Abonnement aufbaut,
keine Anfrage hat, aus der er einen Hostnamen ableiten könnte. Das Speichern fordert außerdem jeden
Worker zum Neustart auf, damit er den Wert übernimmt, statt bis zu seinem nächsten Recycling am
alten festzuhalten. Ein über die Umgebung gesetztes APP_PUBLIC_URL gewinnt gegen das, was der
Bildschirm gespeichert hat — eine Installation, die es setzt, bleibt davon also unberührt.
Der typische Fehlerfall ist, die Frage nach der öffentlichen URL mit https://localhost zu
beantworten. Das wird akzeptiert, und es ist die Vorbelegung, wenn du plMail auf der Maschine
einrichtest, auf der es läuft — aber Google und Microsoft werden eine Loopback-Adresse nie
erreichen, also bleibt Push für Mail und Kalender aus. Korrigiere es später über die Umgebung, oder
rufe die Einrichtungsseite über die Adresse auf, die du tatsächlich verwenden willst.
Wofür jeder Container da ist
| Dienst | Was darin läuft | Warum er ein eigener Container ist |
|---|---|---|
secrets-init |
generate-secrets, dann Ende |
Postgres braucht sein Passwort beim Anlegen des Containers, bevor die Anwendung existiert |
php |
FrankenPHP, das die Anwendung ausliefert | Der einzige Anwendungsdienst mit HTTP-Server und damit der einzige, dessen Image-Healthcheck aktiv bleibt — der Worker schaltet ihn ab und meldet Lebendigkeit stattdessen über Heartbeats |
worker |
app:work: jeder Hintergrundprozess, jeweils als eigener Prozess |
Alles, was nicht der Webserver ist. Im Netz antwortet er auf den Namen mercure, weil er den Hub betreibt |
database |
postgres:18-alpine mit vorgeladenem pg_stat_statements |
— |
ntfy |
ntfy, unter dem Profil push |
Optional. Android-Push ohne Google — starte ihn mit docker compose --profile push up -d |
Die Prozesse des Workers. Jeder wird wieder gestartet, wenn er endet: sofort, wenn er sauber endete (ein Consumer, der an seinem Zeit- oder Speicherlimit neu anfängt), nach einer Wartezeit von bis zu dreißig Sekunden, wenn er abgestürzt ist.
| Prozess | Was darin läuft | Wofür er da ist |
|---|---|---|
mercure |
Der Mercure-Hub, 1.x | Live-Aktualisierungen — die Mailliste, die sich von selbst auffrischt |
imap-supervisor |
app:imap:supervise |
Startet und überwacht je einen app:imap:idle-Prozess pro IDLE-fähigem Postfach, damit gewöhnliche IMAP-Mail in dem Moment ankommt, in dem sie eintrifft |
worker-export |
messenger:consume export |
Alles, was plMail verlässt, und die einzige Warteschlange, auf die jemand wartet. In einem eigenen Prozess, damit ein Versand nie hinter einem Abgleich steht |
worker-ingest |
messenger:consume ingest |
Mail abholen, die gerade ankommt, und nur das |
worker-backlog |
messenger:consume ingest_backlog |
Die Vorgeschichte eines Postfachs beim ersten Import, Seite für Seite. In einem eigenen Prozess, damit gerade ankommende Mail nie dahinter steht |
worker-live |
messenger:consume enrich_live |
Was auf Mail folgt, die gerade eben angekommen ist — Einsortieren, Terminerkennung, Insight-Karten, Lesebestätigungen. Eigener Prozess, damit neue Mail nie hinter einem Import steht |
worker-release |
messenger:consume release |
Zeigt Mail an, die für den Assistenten zurückgehalten wurde, sobald ihre Wartezeit vorbei ist. Fast immer untätig, und in einem eigenen Prozess, damit nie etwas davor steht |
worker-enrich |
messenger:consume enrich enrich_backlog |
Dieselbe Arbeit für die aktuelle Mail eines Imports, danach — nur wenn diese Warteschlange leer ist — alte Mail, die der Assistent einsortiert |
worker-maintenance |
messenger:consume maintenance async |
Nachträgliche Verarbeitungen, Regelläufe über vorhandene Mail, administrative Durchläufe. Leert außerdem die stillgelegte Warteschlange async |
worker-bulk |
messenger:consume bulk |
Aktionen über eine ganze Ansicht: alles Ungelesene als gelesen markieren, alles archivieren. Eigener Prozess, weil jemand dabei auf eine Fortschrittsanzeige schaut und das nicht hinter einer nachträglichen Verarbeitung warten darf |
scheduler |
messenger:consume scheduler_default |
Löst alles Wiederkehrende aus. Ohne diesen Prozess plant sich nichts von selbst |
Vier Warteschlangen-Prozesse statt vier Transports in einem, weil ein Worker, der bereits in einem
langen Handler steckt, nichts anderes mehr annehmen kann, wie auch immer die Warteschlangen
priorisiert sind. Genau das war das ursprüngliche Problem: Ein Klick auf Senden wartete hinter
einem Gmail-Batch. Früher waren sie je ein Container, jetzt sind sie je ein Prozess, und das war
der Teil, auf den es ankam. Jeder behält seinen alten Namen als APP_CONTAINER_NAME, nach dem sich
die Heartbeats, /healthz und die Protokollansicht im Administrationsbereich richten, sie lesen
sich also genau wie vorher.
Der Hub ist das offizielle Mercure-1.x-Programm, ins plMail-Image kopiert statt aus einem eigenen Image betrieben. Es ist nicht der in FrankenPHP eingebaute Hub, denn der ist eine 0.x-Version, und plMail spricht das 1.0-Protokoll.
Wieder aufteilen geht mit app:work --only=… und --without=…. Ein Container mit
app:work --only=worker-bulk neben einem mit app:work --without=worker-bulk gibt der
Bulk-Warteschlange einen eigenen Container und ein eigenes Speicherlimit. Nur ein Container darf
mercure betreiben, und das ist der, der den Netzwerk-Alias mercure braucht.
Umsteigen von einem Container pro Prozess muss nicht sofort sein: Das Image betreibt jeden
dieser Dienste weiterhin wie bisher, und der separate Hub aus dunglas/mercure funktioniert auch
weiterhin. Ersetze die Compose-Datei durch die aktuelle, wenn es dir passt. Der Hub behält seinen
Verlauf, weil der Worker dasselbe Volume mercure_data einhängt.
Der typische Fehlerfall ist, den Prozess scheduler wegzulassen, mit --only oder
--without. Ohne ihn wird überhaupt nichts Wiederkehrendes ausgelöst — kein Abgleich per Polling,
kein Aufwachen zurückgestellter Konversationen, kein Kalenderabgleich, keine Erinnerungen, kein
Aufräumen — und nirgends erscheint ein Fehler, weil nichts fehlgeschlagen ist.
php bin/console debug:scheduler listet auf, was laufen sollte.
Speicher, und was die Standarddatei nicht dauerhaft ablegt
Die Compose-Datei deklariert zehn benannte Volumes:
| Volume | Enthält |
|---|---|
app_secrets |
generated.env, postgres_password, das JWT-Schlüsselpaar. Von jedem App-Dienst eingehängt, in database nur lesend |
app_attachments, app_raw, app_uploads |
Anhänge, Rohnachrichten und zwischengelagerte JMAP-Uploads. Von jedem App-Dienst eingehängt, denn die Worker schreiben sie und der Web-Container liefert sie aus |
database_data |
Der PostgreSQL-Cluster |
caddy_data, caddy_config |
Caddys TLS-Material und Zustand |
mercure_data, mercure_config |
Zustand des Hubs, vom Worker eingehängt |
ntfy_data |
Zustand der Benachrichtigungs-Topics |
Die drei Blob-Volumes fehlten bis vor Kurzem, und der Fehler war auf lehrreiche Weise lautlos. Das
Dockerfile hat bewusst kein VOLUME /app/var/ — ein anonymes Volume dort gab jedem Container seine
eigene Kopie —, die dauerhaften Pfade werden also pro Dienst deklariert. Der Entwicklungs-Stack
und das TrueNAS-Deployment deklarierten sie. Die Standarddatei compose.yaml, also genau die Datei,
zu der die README greifen lässt, nicht. Anhänge, die ein Sync-Worker schrieb, waren damit für den
Web-Container unsichtbar, der den Download ausliefern sollte, und beide Kopien starben beim
nächsten docker compose up, das einen Container neu anlegte. Nichts warf einen Fehler: Mail kam
an, die Liste zeichnete sich, und der Download lieferte 404.
Wenn du eine Standard-compose.yaml von vor dieser Korrektur betreibst, lies den
Deployment-Hinweis in
CHANGELOG.md, bevor du ziehst.
Ein leeres Volume über ein Verzeichnis zu hängen verdeckt, was darin liegt — gelöscht wird nichts,
aber die Dateien sind nicht mehr sichtbar, bis sie herüberkopiert sind.
Die TrueNAS-App geht einen anderen Weg: Sie setzt APP_STORAGE_DIR auf ein Datenverzeichnis und
bindet dort einen einzigen Host-Pfad, sodass Anhänge, Rohnachrichten, Uploads und die
Geheimnisse alle unter einem Pfad landen — eine Sache zum Snapshotten und eine zum Sichern.
Alltagsbetrieb
docker compose ps # what is up
docker compose logs -f php # the web container
docker compose logs -f scheduler # the recurring jobs
docker compose exec php php bin/console list
Als Administrator angemeldet findest du die Zahlen unter Administration: welche Worker leben, wie tief die Warteschlangen sind, der Push-Zustand pro Konto, die Datenbankgröße und ein durchsuchbares Protokoll. Siehe Administration.
Die Konsolenbefehle sind in CONTRIBUTING aufgeführt; die,
zu denen man beim Betrieb am häufigsten greift, sind app:backup, app:user:promote,
app:user:2fa-disable und app:mail:test-connection.
Der typische Fehlerfall ist, einen Konsolenbefehl in einem Container auszuführen, der die
Geheimnisse nicht sieht. docker compose exec php … ist in Ordnung —
config/bootstrap_generated_secrets.php lädt die erzeugte Datei genau für diesen Fall. Ein blankes
docker run gegen das Image ist es nicht.
Wie es weitergeht
- Postfächer hinzufügen: IMAP und SMTP, Google, Microsoft, CalDAV
- Von außen erreichbar machen: Hinter einem Reverse-Proxy
- Alles Konfigurierbare: Konfigurationsreferenz
- Bevor echte Mail darin liegt: Sichern und Wiederherstellen
Fallstricke
docker compose up liest eine compose.override.yaml mit, ob du das wolltest oder nicht.
plMail liefert keine mit; liegt eine da, hat sie jemand dort abgelegt. Lösch sie, außer du weißt,
was sie tut.
Jeder Anwendungsdienst muss app_secrets einhängen. Ein Dienst ohne diesen Mount erzeugt sich
ein eigenes APP_ENCRYPTION_KEY und kann fortan nicht mehr lesen, was die anderen geschrieben
haben. EncryptionKeyProbe bemerkt das beim Start und verweigert den Start des Servers, statt
Konten zu speichern, die sonst niemand lesen kann.
Ein erster Abgleich stellt Tausende Jobs in die Warteschlange, und das ist kein Fehler.
/healthz bewertet die Warteschlange erst jenseits von 5000 wartenden Nachrichten als überlastet —
gerade damit ein legitimer erster Abgleich keine Störung meldet. Siehe
Fehlersuche.
Migrationen laufen bei jedem Start automatisch. Das ist Absicht, und es hat Folgen für das Zurückrollen — lies Aktualisieren, bevor du ein neues Image ziehst.
This page is generated from docs/de/install/docker.md. Edit it there — changes made here are overwritten on the next push to main.
plMail
Using plMail
- Accounts and aliases
- Account health
- Templates
- Filters
- Calendar
- Invitations and events from mail
- Reminders
- Connected calendars
- Sharing and booking
- Contacts
- Files and integrations
- Security
- Other clients
- Appearance
- Administration
Installing and running
- Docker Compose
- Platform notes
- Behind a reverse proxy
- Configuration reference
- Backup and restore
- Configuration backup
- Upgrading
- Demo mode
- Troubleshooting
Providers
How it works