Starting plMail took over two minutes on a NAS with spinning disks, and the log was silent for all of it — between "Statement statistics are enabled" and the web server's first line. An empty install on the same box, from the same image, was up in seconds. The entrypoint ended with two `setfacl -R` passes over var/cache and var/log. var/cache is part of the image: about 2,200 files. Changing anything about an image file from inside a container, an ACL included, makes the storage driver copy the whole file into the container's layer and flush it. So every new container rewrote its cache one synchronous file at a time, in every container of the stack at once, on one pool. Measured in a fresh container on an SSD: 1.2 s for the first pass, 0.03 s for the second. On the warm production container, 0.06 s — which is why timing it there showed nothing. It ran only as root, so the catalogue install (uid 568) never paid it. Nothing needed the entries: nothing runs as www-data, root needs no grant, and the Dockerfile already makes both directories writable. This is the third time that block has been the answer to a slow start. It walked all of var/, then var/cache and var/log, and now nothing. Also, on the compose files here: the database health check had no interval, so Docker's first probe came thirty seconds after start and the web server and workers could not begin before it. Five seconds now, with retries raised so a database in trouble is called unhealthy after the same two and a half minutes as before. The README sends TrueNAS users to the catalogue app instead of walking them through truenas.compose.yaml. The file stays: installs made from it are still running. Not confirmed on the box that reported it; the copy-up cost is measured locally and inferred there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 KiB
Platform notes
plMail runs the same containers everywhere, so most of this page is about the two things that genuinely differ between a Linux server, a Mac, Windows via WSL2 and a NAS: who owns the files behind a bind mount, and how much memory the Docker host is actually willing to give the stack. Start from Docker Compose; this covers only the deltas.
Images are published for linux/amd64 and linux/arm64 as one manifest, so every platform below
runs plMail natively. There is no QEMU step to plan around, on an Apple Silicon Mac or on an ARM
NAS.
What the stack asks of the host
The stock file starts eight long-running containers — the web server, the IMAP supervisor, four
Messenger workers, Postgres and the Mercure hub — plus secrets-init, which exits. Under the
optional push profile there is a ninth, ntfy.
The numbers that are actually written down: PHP's memory_limit is 2G in
frankenphp/conf.d/10-app.ini, each Messenger worker is bounded by --memory-limit=256M and
recycles at --time-limit=3600, and upload_max_filesize is 25M with post_max_size at 60M.
Postgres runs with shared_preload_libraries=pg_stat_statements and whatever the image's own
defaults give it.
The failure mode is a host that overcommits and then OOM-kills. A worker killed mid-handler leaves its message to be redelivered, which is survivable; the container that gets killed holding the migration advisory lock is not — every other container waits out the five-minute lock timeout and then refuses to migrate.
Linux server
The straightforward case, and the one the stock compose.yaml is written for: every durable path
is a named volume, which Docker creates and owns, so there is nothing to chown and no
ownership question to answer.
The containers run as root internally, so they can already write everything under var/. The
entrypoint sets no ACLs: it used to, on every start, and that is what made a start take minutes on
spinning disks — see Troubleshooting.
Two things change the moment you swap a named volume for a bind mount, which you will do if you want the mail on a specific disk:
- Postgres runs as uid 70 in
postgres:18-alpine— 999 is the Debian image's. It cannot create its own data directory inside a root-owned parent, so the parent has to exist with the right ownership before the container starts.truenas.compose.yamlsolves this by havingsecrets-initlay out andchown -R 70:70the Postgres subdirectory before anything else runs; that pattern transplants to any bind-mounted deployment. - ACLs are not needed. ZFS, NFS and anything else using NFSv4 ACLs refuse
setfaclwith "Operation not supported". plMail no longer calls it at start, so there is nothing to refuse.
The failure mode is a bind mount whose parent Docker created as root. Postgres exits during initialisation with a permissions error that names a path, and the app containers then spend their 60 database attempts waiting for something that will never come up.
Windows, via WSL2
Docker Desktop with the WSL2 backend runs the same Linux containers, so nothing about the stack changes. Two host-side details do.
Keep the project inside the WSL2 filesystem. A path under /home/... in the distribution is a
native Linux filesystem; a path under /mnt/c is Windows' filesystem reached through a translation
layer, with different performance and different semantics for file locking and ownership. Named
volumes sidestep the question entirely, which is another argument for leaving the stock file's
volumes alone.
Memory belongs to the WSL2 VM, not to Windows. The figures in "What the stack asks of the host"
above come out of whatever WSL2 has been allowed to take, which is configured in .wslconfig and
not in Docker. A stack that behaves as if the machine were much smaller than it is usually means
that file.
If you also intend to run the browser test suite from Windows, watching a run needs WSLg on Windows 11 or an X server; without one, use the trace-recording variant and read the recording afterwards. That is a development concern only — CONTRIBUTING covers it.
The failure mode is bind-mounting a Windows path into the containers. It appears to work, and
then the file-locking behaviour underneath differs from what the entrypoint expects — the same
class of problem that made flock unusable as the dependency-install mutex on macOS.
macOS
Apple Silicon pulls the linux/arm64 image and runs it natively.
The relevant difference is again the filesystem behind a bind mount. Docker Desktop shares host directories over VirtioFS, and two things there are documented in this repository because they were measured rather than assumed:
setfaclfails on a VirtioFS share. The entrypoint no longer has an ACL step for that to matter to.flockis advisory and does not reliably exclude across containers on such a mount. The dev entrypoint's dependency install usesmkdiras its mutex instead, because it is a single atomic operation that fails withEEXIST. Two containers were observed entering aflock-guarded block 17ms apart before that changed.
Neither affects the published image, which bakes vendor/ in and uses named volumes — they matter
if you bind-mount a source tree, which is the development setup.
As on Windows, the memory available is the VM's, configured in Docker Desktop, not the Mac's total.
The failure mode is assuming a bind mount behaves like a local disk. It does for reading and writing; it does not for locks, ACLs or ownership, and every one of those has already broken something here.
NAS boxes
A NAS is squarely the kind of machine plMail is meant to run on, and the repository ships
truenas.compose.yaml as a worked example — read it even if your NAS is not a TrueNAS, because
every decision in it is one you will face.
Ports. A NAS whose own management UI already holds 80 and 443 needs plMail moved. Set
HTTP_PORT and HTTPS_PORT (and HTTP3_PORT if you serve HTTP/3 directly), or do what the
TrueNAS file does: give the php service SERVER_NAME: ":80" so FrankenPHP serves plain HTTP, map
that to a high port — 30080 there — and terminate TLS in front of it. See
Behind a reverse proxy.
One directory instead of six. truenas.compose.yaml sets APP_STORAGE_DIR=var/data and points
APP_SECRETS_DIR and APP_SECRETS_FILE inside the same tree, then binds one host path at
/app/var/data on every service. The result is a single directory holding the secrets,
attachments, raw messages, uploads and the Postgres cluster — one thing to snapshot, one thing to
back up, and every service demonstrably seeing the same encryption key. Point it at a dataset
rather than a plain directory if you want snapshots of your mail.
Ownership, again. That file's secrets-init creates the subdirectories, chown -R 70:70s the
Postgres one, and then does something worth copying:
chmod o+x /app/var/data || setfacl -m u:70:--x /app/var/data || true
A dataset created with the TrueNAS "Apps" preset is 770 apps:apps, which gives uid 70 no way to
traverse into the directory its own data lives in. Execute-only grants traversal without granting
the ability to list or read anything.
Interpolation. TrueNAS's YAML installer does not support ${VAR}, which is why that file
hoists every setting into an x-config block and aliases it with YAML anchors. If your NAS's app
UI has the same limitation, the same trick applies; if it runs real docker compose, the stock
file and a .env beside it are simpler.
Image tags matter more here, because a NAS app page usually has a "redeploy" button and no
obvious version. truenas.compose.yaml sets pull_policy: always, so a redeploy picks up a newer
build of whichever tag is set. Choose the tag deliberately — see Upgrading.
Android push without Google is the one optional container most worth starting on a NAS:
docker compose --profile push up -d brings up ntfy, which owns the endpoint URL a UnifiedPush
distributor on the phone talks to. Its base URL is derived from SERVER_NAME and is baked into
every endpoint handed out, so getting NTFY_BASE_URL right before handing the address to phones
saves re-registering every device later.
The failure mode is a NAS app UI that hides which containers exist. The one to check for is
scheduler. An install missing it looks completely healthy and quietly never polls, never wakes a
snoozed thread, never syncs a calendar and never fires a reminder.
Things that bite
Only some of these platforms give you named volumes for free. The stock compose.yaml uses
them, and its blob directories are not among them — see
the storage section of the Docker page.
On a NAS you will almost certainly be bind-mounting instead, and then ownership is yours to get
right.
Postgres is not the same uid in every image. It is 70 in the alpine images plMail uses and 999
in the Debian ones. A chown copied from a guide written for the other leaves the cluster unable
to write.
The entrypoint sets no ACLs, and must not start to. var/cache and var/log are part of the
image. Changing anything about an image file from inside a container — an ACL counts — makes the
storage driver copy the whole file into the container's own layer and flush it to disk. Two
recursive setfacl passes at start therefore rewrote two thousand files synchronously, in every
container of the stack at once: about a second on an SSD and over two minutes on a pool of
spinning disks. The image makes both directories writable at build instead, where it is paid once.
ARM is a first-class target and must stay that way. Both architectures are built on native runners and merged into one manifest, so anything you add to a self-built image has to resolve an arm64 asset too.
A stack that is fine for months and then stops on a first sync is a memory problem. The first sync of a large mailbox is the busiest plMail ever gets: it queues thousands of jobs, four workers run concurrently against them, and each is allowed 256M before it recycles.