The stock compose file served https://localhost and nothing else. Reached by address or hostname, Caddy had no certificate to offer and ended the handshake, which a browser reports as an SSL protocol error with no way to proceed. A headless server could not be set up without first setting SERVER_NAME. The default now answers every name and address with the self-signed localhost certificate (default_sni/fallback_sni, and `:443` in the default SERVER_NAME), so the browser shows its usual warning. Live updates follow: compose.yaml's https://localhost hub address is treated as the placeholder it is and gives way to the public address stored during setup. Any other MERCURE_PUBLIC_URL, and any SERVER_NAME an operator sets, is left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
The failure mode is a stray compose.override.yaml. Compose loads that file automatically
whenever it exists, and the one in this repository switches every service from the published image
to a locally built dev image with the source bind-mounted. It is untracked for exactly that reason;
only compose.override.yaml.dist is committed. Do not copy it unless you are developing.
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:
secrets-initruns and exits. It mintsAPP_SECRET,APP_ENCRYPTION_KEY,POSTGRES_PASSWORDandMERCURE_JWT_SECRETintovar/secrets/generated.envon the sharedapp_secretsvolume, plus a barepostgres_passwordfile. 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.databasestarts and reads its password throughPOSTGRES_PASSWORD_FILE. Every app service waits on its healthcheck, which allows a 60-second start period.- Each app container runs the same entrypoint. It loads the generated secrets, assembles
DATABASE_URLfromPOSTGRES_PASSWORDunless you supplied a DSN with a password of your own, waits up to 60 attempts for the database, then runsapp:db:migrate— Doctrine's migrate under a Postgres advisory lock, so the containers booting together cannot collide. app:secrets:initruns, 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.- Caddy serves. The
phpcontainer publishes 80/tcp, 443/tcp and 443/udp — remappable withHTTP_PORT,HTTPS_PORTandHTTP3_PORT— and serves the names inSERVER_NAME, whichcompose.yamlsets tolocalhost, :443, php:80:localhostis the name the self-signed certificate is made for, and:443answers 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, and only that |
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 two
deployment files that people actually edited, compose.override.yaml.dist and
truenas.compose.yaml, 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.
truenas.compose.yaml takes a different approach: it sets APP_STORAGE_DIR=var/data and binds one
host directory at /app/var/data, 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
- Adding mailboxes: IMAP and SMTP, Google, Microsoft, CalDAV
- Reaching it from outside: Behind a reverse proxy
- Everything configurable: Configuration reference
- Before you have real mail in it: Backup and restore
Things that bite
docker compose up picks up compose.override.yaml whether you meant it or not. A copy left
behind from a development experiment silently switches the whole stack to a locally built image
with no application code baked in — the entrypoint then falls into its symfony/skeleton branch and
the container crash-loops.
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.
compose.prod.yaml builds from source and demands APP_SECRET. It sets
target: frankenphp_prod and APP_SECRET: ${APP_SECRET} with no default, so it fails outright when
that variable is unset — and setting it switches off generation for that value. It exists for
people who build their own image, not as the normal path.
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.