Files
pl_mail/docs/install/platforms.md
T
Paul LütznerandClaude Opus 5.5 9c4cebf8b9 Stop rewriting the cache directory at every start
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>
2026-10-07 17:07:40 +02:00

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.yaml solves this by having secrets-init lay out and chown -R 70:70 the 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 setfacl with "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:

  • setfacl fails on a VirtioFS share. The entrypoint no longer has an ACL step for that to matter to.
  • flock is advisory and does not reliably exclude across containers on such a mount. The dev entrypoint's dependency install uses mkdir as its mutex instead, because it is a single atomic operation that fails with EEXIST. Two containers were observed entering a flock-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.