Files
pl_mail/docs/install/docker.md
T
Paul LütznerandClaude Opus 5.5 ffbf7bdd9b Clean up the compose files (#160)
Compose files keep only a short header; the reasoning lives in
docs/install. truenas.compose.yaml is gone (the TrueNAS catalog app
replaces it) and so is compose.prod.yaml (compose.yaml already runs the
published prod image). compose.override.yaml.dist becomes compose.dev.yaml,
passed with -f, so there is no untracked copy that compose loads on its
own. Docs, CODESTYLE, CONTRIBUTING and the entrypoint's error text follow.

Every remaining file resolves to the same `docker compose config` as
before.

Closes #30

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:22 +02:00

16 KiB

Installing with Docker Compose

The supported path, start to finish: what you need, what docker compose up actually does, how the first administrator gets created, and what each of the containers is for.

Everything below assumes the stock compose.yaml from the repository, which pulls the published image. Building from source is a development setup and belongs in CONTRIBUTING.

What you need

  • A machine running Docker and Docker Compose.
  • Nothing else. There is no configuration to fill in before the first start.

Images are published for linux/amd64 and linux/arm64, each built on a native runner and merged into one manifest, so an Apple Silicon Mac, an ARM NAS or a 64-bit Raspberry Pi runs plMail without emulation. Which machine you are on changes a few details — see Platform notes.

Sizing is worth a thought rather than a rule. PHP's memory_limit is 2G in the image, each of the four Messenger workers is capped by --memory-limit=256M and recycles on --time-limit=3600, and Postgres, the Mercure hub and the IMAP supervisor each want their own share on top. The workers, the hub and the IMAP supervisor share one container, so a memory limit on it covers all of them. A first sync of a large mailbox is the busiest this ever gets.

That is enough for IMAP mailboxes. Gmail and Outlook additionally need OAuth credentials — see Google and Microsoft.

The failure mode is expecting a mail server. plMail is a client. It does not receive mail from the outside world, does not host your domain, and needs no port 25 anywhere.

Getting it

git clone https://github.com/karatektus/pl_mail.git
cd pl_mail

The clone is for compose.yaml and nothing else — the application code lives in the image. If you would rather not clone, copying compose.yaml on its own is enough, as long as you also create a .env beside it or accept every default in the configuration reference.

Do not add -f compose.dev.yaml unless you are developing. It switches every service from the published image to a locally built dev image with the source bind-mounted.

Starting it

docker compose up -d

Then open https://localhost.

On a machine with no browser of its own, open https:// and that machine's address or hostname from another one — https://192.168.1.20, say. Either way the certificate is one the container signed for itself, so the browser warns once and lets you continue. To serve a certificate browsers trust, set SERVER_NAME to a domain that resolves to this machine, or put a reverse proxy in front.

Finish the setup screen before you go anywhere else. Until the first user exists, /install is open to whoever reaches it — that is how you create your own account, and it means anybody who reaches the instance first becomes its administrator instead. This is the usual bargain for self-hosted software and it is a real window, because the README also suggests putting a reverse proxy in front and TrueNAS exposes port 30080. Create the account, and the window closes.

The first boot takes longer than later ones, and it does a specific sequence of things worth knowing about:

  1. secrets-init runs and exits. It mints APP_SECRET, APP_ENCRYPTION_KEY, POSTGRES_PASSWORD and MERCURE_JWT_SECRET into var/secrets/generated.env on the shared app_secrets volume, plus a bare postgres_password file. It runs before everything else because Postgres reads its password when its container is created and cannot wait for the app to hand it one. On every later start it finds the file and does nothing.
  2. database starts and reads its password through POSTGRES_PASSWORD_FILE. Every app service waits on its healthcheck, which allows a 60-second start period.
  3. Each app container runs the same entrypoint. It loads the generated secrets, assembles DATABASE_URL from POSTGRES_PASSWORD unless you supplied a DSN with a password of your own, waits up to 60 attempts for the database, then runs app:db:migrate — Doctrine's migrate under a Postgres advisory lock, so the containers booting together cannot collide.
  4. app:secrets:init runs, after migrations. It verifies that the encryption key in force can decrypt the credentials already stored, then generates a VAPID keypair and the JMAP JWT keypair if they are missing.
  5. Caddy serves. The php container publishes 80/tcp, 443/tcp and 443/udp — remappable with HTTP_PORT, HTTPS_PORT and HTTP3_PORT — and serves the names in SERVER_NAME, which compose.yaml sets to localhost, :443, php:80: localhost is the name the self-signed certificate is made for, and :443 answers every other name and address with that same certificate. It also proxies /.well-known/mercure* to the hub container, so the browser reaches the hub same-origin and needs no CORS.

docker compose up -d --wait blocks until the healthchecks pass, which is the better form in a script.

The failure mode is reading the logs of the wrong container. Six services run the same image and all six migrate on boot; the ones that lost the race for the advisory lock print "another container is running migrations" and are not the ones to debug.

Creating the first administrator

Open the app and it offers to create one. The /install page asks for a name, an address, a password and the public URL plMail will be reached at — prefilled with the address you opened it on, which is right whenever you are reaching it the same way its users will.

The same thing from a terminal:

docker compose exec php php bin/console app:setup

/install is unauthenticated because there is nobody to authenticate. It is guarded by exactly one thing — the install having no users — checked when the page renders, when the form is submitted, and a third time inside the locked write. The moment a user exists the page 404s for good.

The public URL you give is written to var/secrets/generated.env rather than to a config file, because a long-running worker building a push subscription has no request to infer a hostname from. Saving it also asks every worker to restart so they pick it up rather than holding the old value until they recycle. An APP_PUBLIC_URL supplied through the environment wins over what the screen stored, so a deployment that sets it is untouched by this.

The failure mode is answering the public-URL question with https://localhost. It is accepted, and it is what the prefill offers when you set plMail up on the machine it runs on — but Google and Microsoft will never reach a loopback address, so calendar and mail push stay off. Fix it later from the environment, or reach the setup page through the address you actually intend to use.

What each container is for

Service What it runs Why it is its own container
secrets-init generate-secrets, then exits Postgres needs its password at container-create time, before the app exists
php FrankenPHP serving the app The only app service with an HTTP server, and therefore the only one whose image healthcheck is left enabled — the worker disables it and reports liveness through heartbeats instead
worker app:work: every background process, one process each Everything that is not the web server. It answers to mercure on the network, because it runs the hub
database postgres:18-alpine with pg_stat_statements preloaded —
ntfy ntfy, under the push profile Optional. Android push without Google — start it with docker compose --profile push up -d

The worker's processes. Each is started again when it stops: at once when it stopped cleanly (a consumer recycling at its time or memory limit), after a backoff of up to thirty seconds when it crashed.

Process What it runs What it is for
mercure The Mercure hub, 1.x Live updates — the mail list refreshing by itself
imap-supervisor app:imap:supervise Spawns and watches one app:imap:idle process per IDLE-enabled mailbox, so standard IMAP mail arrives the moment it lands
worker-export messenger:consume export Anything leaving plMail, and the only queue somebody is watching. On its own process so a send is never behind a sync
worker-ingest messenger:consume ingest Fetching mail that is arriving now, and only that
worker-backlog messenger:consume ingest_backlog A mailbox's history on first import, a page at a time. On its own process so mail arriving now is never behind it
worker-live messenger:consume enrich_live What follows mail that has just arrived — sorting, event extraction, insight cards, receipts. On its own process so new mail is never behind an import
worker-release messenger:consume release Shows mail that was held for the assistant once its wait is over. Idle nearly always, and on its own process so that nothing is ever in front of it
worker-enrich messenger:consume enrich enrich_backlog The same work for an import's recent mail, then — only when that queue is empty — old mail being sorted by the assistant
worker-maintenance messenger:consume maintenance async Backfills, rule runs over existing mail, admin sweeps. Also drains the retired async queue
worker-bulk messenger:consume bulk Whole-view actions: mark every unread read, archive the lot. On its own process because somebody is watching an indicator for it and it must not wait behind a backfill
scheduler messenger:consume scheduler_default Fires everything recurring. Nothing schedules itself without this process

Four queue processes rather than four transports on one, because a worker already inside a long handler cannot pick up anything else however the queues are prioritised. That was the original problem: pressing Send behind a Gmail batch waited for the batch. They used to be one container each; they are one process each now, which is the part that mattered. Each keeps its old name as APP_CONTAINER_NAME, which the heartbeats, /healthz and the admin log viewer go by, so they read exactly as they did.

The hub is the official Mercure 1.x binary, copied into the plMail image rather than run from its own. It is not the hub FrankenPHP has built in, which is a 0.x release, and plMail speaks the 1.0 protocol.

Splitting it up again is app:work --only=… and --without=…. A container running app:work --only=worker-bulk beside one running app:work --without=worker-bulk gives the bulk queue a container, and a memory limit, of its own. Only one container may run mercure, and it is the one that needs the mercure network alias.

Upgrading from one container per process needs nothing at once: the image still runs each of those services as it did, and the separate dunglas/mercure hub still works. Replace the compose file with the current one when convenient. The hub keeps its history, because the worker mounts the same mercure_data volume.

The failure mode is leaving the scheduler process out with --only or --without. Without it nothing recurring fires at all — no polling sync, no snooze wake, no calendar sync, no reminders, no pruning — and there is no error anywhere, because nothing failed. php bin/console debug:scheduler lists what should be running.

Storage, and what the stock file does not persist

The compose file declares ten named volumes:

Volume Holds
app_secrets generated.env, postgres_password, the JWT keypair. Mounted by every app service, and read-only into database
app_attachments, app_raw, app_uploads Attachments, raw message sources, and staged JMAP uploads. Mounted by every app service, because the workers write them and the web container serves them
database_data The PostgreSQL cluster
caddy_data, caddy_config Caddy's TLS material and state
mercure_data, mercure_config Hub state, mounted by the worker
ntfy_data Notification topic state

The three blob volumes were missing until recently, and the failure was silent in an instructive way. The Dockerfile deliberately has no VOLUME /app/var/ — an anonymous volume there gave every container its own copy — and the durable paths are instead declared per service. The development stack and the TrueNAS deployment both declared them. The stock compose.yaml, which is the file the README tells an operator to run, did not. So attachments written by a sync worker were invisible to the web container serving the download, and both copies died with the next docker compose up that recreated a container. Nothing errored: mail synced, the list rendered, and the download 404'd.

If you ran a stock compose.yaml from before that fix, read the deployment note in CHANGELOG.md before pulling. Mounting an empty volume over a directory hides what is inside it — nothing is deleted, but the files stop being visible until they are copied across.

The TrueNAS app takes a different approach: it sets APP_STORAGE_DIR to one data directory and binds one host path there, so attachments, raw messages, uploads and the secrets all land under a single path — one thing to snapshot and one thing to back up.

Everyday operation

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

Signed in as an administrator, Admin is where the numbers live: which workers are alive, queue depth, push state per account, database size and a searchable log. See Administration.

The console commands are listed in CONTRIBUTING; the ones an operator reaches for most are app:backup, app:user:promote, app:user:2fa-disable and app:mail:test-connection.

The failure mode is running a console command in a container that cannot see the secrets. docker compose exec php … is fine — config/bootstrap_generated_secrets.php loads the generated file for exactly that case. docker run against the image on its own is not.

Where to go next

Things that bite

docker compose up picks up a compose.override.yaml whether you meant it or not. plMail does not ship one, so if one exists, someone put it there. Delete it unless you know what it does.

Every app service must mount app_secrets. A service missing that mount mints its own APP_ENCRYPTION_KEY and stops being able to read what the others wrote. EncryptionKeyProbe catches it at boot and refuses to start the server rather than save accounts nothing else can read.

A first sync queues thousands of jobs, and that is not a fault. /healthz only calls the queue backed up past 5000 pending messages, precisely so a legitimate first sync does not report an outage. See Troubleshooting.

Migrations run automatically on every boot. That is deliberate, and it has consequences for rolling back — see Upgrading before you pull a new image.