Clone
8
De Install Docker
github-actions[bot] edited this page 2026-10-15 09:43:37 +00:00

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:

  1. secrets-init läuft und beendet sich. Es erzeugt APP_SECRET, APP_ENCRYPTION_KEY, POSTGRES_PASSWORD und MERCURE_JWT_SECRET in var/secrets/generated.env auf dem gemeinsamen Volume app_secrets, dazu eine nackte Datei postgres_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.
  2. database startet und liest sein Passwort über POSTGRES_PASSWORD_FILE. Jeder Anwendungsdienst wartet auf dessen Healthcheck, der eine Startphase von 60 Sekunden zugesteht.
  3. Jeder Anwendungscontainer führt denselben Entrypoint aus. Er lädt die erzeugten Geheimnisse, setzt DATABASE_URL aus POSTGRES_PASSWORD zusammen — es sei denn, du hast eine DSN mit einem eigenen Passwort mitgegeben —, wartet bis zu 60 Versuche lang auf die Datenbank und führt dann app:db:migrate aus: Doctrines Migrate unter einem Postgres-Advisory-Lock, damit die gemeinsam startenden Container nicht kollidieren.
  4. app:secrets:init lä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.
  5. Caddy nimmt den Betrieb auf. Der php-Container veröffentlicht 80/tcp, 443/tcp und 443/udp — umlegbar über HTTP_PORT, HTTPS_PORT und HTTP3_PORT — und bedient die Namen aus SERVER_NAME, das compose.yaml auf localhost, :443, php:80 setzt: localhost ist der Name, für den das selbstsignierte Zertifikat ausgestellt wird, und :443 beantwortet 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

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.