Files
pl_mail/truenas.compose.yaml
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

315 lines
15 KiB
YAML

# plMail — TrueNAS SCALE custom app (Apps > Discover > ⋮ > Install via YAML)
#
# TrueNAS's YAML installer does NOT support ${VAR} interpolation, so all config is
# hoisted into the x-config block below and aliased (*name) into the services.
# Edit that block; nothing below it needs touching.
#
# You do NOT need to create any directories by hand. Every bind below carries
# create_host_path, so Docker makes them on first start. Point them at a dataset
# rather than a plain directory if you want ZFS snapshots of your mail.
name: plmail
# ─────────────────────────────────────────────────────────────────────────────
# 1. REQUIRED — the only thing to configure. Everything else works as-is.
# ─────────────────────────────────────────────────────────────────────────────
x-config:
# -- Storage --
# One directory. plMail makes its own subfolders inside it — postgres,
# attachments, raw, uploads, secrets — so there is nothing to create and
# nothing else to point anywhere.
#
# This is the whole install. Back it up, or make it a dataset and snapshot
# it: the secrets subfolder holds the key every mailbox password is
# encrypted with, and without it they are gone.
path_data: &path_data /mnt/yourPool/yourDataSet/plmail
# ─────────────────────────────────────────────────────────────────────────────
# 2. OPTIONAL — every one of these works left exactly as it is.
# ─────────────────────────────────────────────────────────────────────────────
# -- Which builds you get --
# The tag on the image decides how often this install changes, and it is
# the one optional setting worth deciding on deliberately:
#
# :latest the most recent tagged release. Moves only when a version is
# released. The default, and what most installs want.
# :main every merge into the default branch, released or not. Follow
# this to run what is being worked on without waiting for a
# version to be tagged — it can break, and the changelog will
# not describe it yet.
# :0.0.14 one release, pinned. Nothing changes until you edit this.
# No leading "v" — the git tag is v0.0.14, the image tag is not.
# :0.0 the newest patch of one minor line.
# :sha-1a2b3c one commit, pinned exactly.
#
# Every service pulls on start (pull_policy: always), so a redeploy from the
# TrueNAS app page is what picks up a newer build of whichever tag is set.
# Downgrading is not supported: migrations run forward on boot, so moving
# from :main back to :latest can leave the database ahead of the code.
image: &image ghcr.io/karatektus/pl_mail:latest
http_port: &http_port 30080
# -- Public address --
# LEAVE BLANK. The setup screen asks for the public URL on first boot and
# stores it; the Mercure hub URL is then derived from it automatically
# (the hub is proxied on the same origin, at /.well-known/mercure).
# Fill these in only to pin values from the environment — anything set
# here wins over what the setup screen stored.
app_public_url: &app_public_url ""
mercure_public_url: &mercure_public_url ""
# -- Secrets --
# LEAVE THESE BLANK. All four are generated on first start into the storage
# directory above. Fill one in only if you manage secrets yourself; nothing is ever
# generated over the top of a value you supplied.
#
# app_encryption_key encrypts every mailbox password and OAuth token at rest.
# Changing it once mail accounts exist makes the app refuse to start rather
# than overwrite what it can no longer read — see CONTRIBUTING.md.
app_secret: &app_secret ""
app_encryption_key: &app_encryption_key ""
db_password: &db_password ""
mercure_jwt: &mercure_jwt ""
# Leave blank and the app assembles it from the generated db_password. Set it
# only to point plMail at a database of your own — and then the password
# inside it MUST equal db_password (an anchor cannot be spliced mid-string):
database_url: &database_url ""
# -- Web Push (browser/PWA notifications + JMAP PushSubscription) --
# Generated on first start if left blank. Rotating them invalidates every
# existing subscription; every device has to re-enable notifications.
# vapid_subject must be a mailto: or https: URL identifying you (RFC 8292).
vapid_subject: &vapid_subject "mailto:you@yourdomain.com"
vapid_public_key: &vapid_public_key ""
vapid_private_key: &vapid_private_key ""
# -- Gmail and Outlook sign-in --
# These are easier to set up from inside plMail: Admin → Integrations walks
# through it and shows you the redirect URI to paste into the provider's
# console. Anything entered there is stored in the database and wins over
# what is here, so most installs can leave this whole block alone.
google_client_id: &google_client_id ""
google_client_secret: &google_client_secret ""
gmail_pubsub_topic: &gmail_pubsub_topic "projects/your-project-id/topics/gmail-push"
# Shared secret appended to the Pub/Sub push endpoint as ?token=… — must
# match the push subscription in Google Cloud or every push is rejected.
gmail_pubsub_token: &gmail_pubsub_token ""
graph_client_id: &graph_client_id ""
graph_client_secret: &graph_client_secret ""
# ─────────────────────────────────────────────────────────────────────────────
x-app-env: &app-env
APP_ENV: prod
APP_PUBLIC_URL: *app_public_url
APP_SECRET: *app_secret
APP_ENCRYPTION_KEY: *app_encryption_key
DATABASE_URL: *database_url
POSTGRES_USER: app
POSTGRES_DB: app
# Everything plMail writes goes under the single mounted directory.
APP_STORAGE_DIR: var/data
APP_SECRETS_DIR: /app/var/data/secrets
APP_SECRETS_FILE: /app/var/data/secrets/generated.env
MESSENGER_TRANSPORT_DSN: "doctrine://default?auto_setup=0"
MERCURE_URL: "http://mercure/.well-known/mercure"
MERCURE_PUBLIC_URL: *mercure_public_url
VAPID_SUBJECT: *vapid_subject
VAPID_PUBLIC_KEY: *vapid_public_key
VAPID_PRIVATE_KEY: *vapid_private_key
MERCURE_JWT_SECRET: *mercure_jwt
GOOGLE_OAUTH_CLIENT_ID: *google_client_id
GOOGLE_OAUTH_CLIENT_SECRET: *google_client_secret
GMAIL_PUBSUB_TOPIC: *gmail_pubsub_topic
GMAIL_PUBSUB_VERIFICATION_TOKEN: *gmail_pubsub_token
MICROSOFT_OAUTH_CLIENT_ID: *graph_client_id
MICROSOFT_OAUTH_CLIENT_SECRET: *graph_client_secret
MICROSOFT_OAUTH_TENANT: common
TRUSTED_PROXIES:
services:
# Mints whatever was left blank above into the storage directory, then exits. Runs
# before everything else: Postgres reads its password when its container is
# created and cannot wait for the app to hand it one.
# Idempotent — on every later start it finds the file and does nothing.
secrets-init:
image: *image
pull_policy: always
restart: "no"
# Lays out the shared directory before anything else starts, then mints
# the secrets. Docker creates the host path as root, and Postgres runs
# as uid 70 in the alpine image (999 is the Debian one) — it cannot make
# its own data directory inside a root-owned parent, which is the one
# thing "subfolders are automatic" cannot leave to the services
# themselves.
entrypoint:
- /bin/sh
- -c
- |
set -eu
mkdir -p /app/var/data/secrets \
/app/var/data/postgres \
/app/var/data/attachments \
/app/var/data/raw \
/app/var/data/uploads
chown -R 70:70 /app/var/data/postgres
# Postgres re-execs as uid 70 and must be able to traverse the
# mount root, but a TrueNAS dataset with the "Apps" preset is
# 770 apps:apps — no x-bit for uid 70. Execute-only: grants
# traversal, not the ability to list or read anything.
chmod o+x /app/var/data || setfacl -m u:70:--x /app/var/data || true
exec generate-secrets
environment:
APP_SECRETS_DIR: /app/var/data/secrets
APP_SECRET: *app_secret
APP_ENCRYPTION_KEY: *app_encryption_key
POSTGRES_PASSWORD: *db_password
MERCURE_JWT_SECRET: *mercure_jwt
volumes:
- type: bind
source: *path_data
target: /app/var/data
bind:
create_host_path: true
php:
image: *image
pull_policy: always
restart: unless-stopped
environment:
<<: *app-env
# ":80" = FrankenPHP serves plain HTTP; terminate TLS at your reverse proxy (recommended).
# For a domain forwarded straight to :443 here, set this to that hostname and re-add the 443 ports.
SERVER_NAME: ":80"
MAILER_DSN: "null://null"
APP_CONTAINER_NAME: web
ports:
- target: 80
published: *http_port
protocol: tcp
volumes:
# Named volumes rather than host paths: these hold TLS material
# Caddy regenerates by itself, so there is nothing here worth a
# dataset of its own — and two fewer paths to get right.
- type: volume
source: caddy_data
target: /data
- type: volume
source: caddy_config
target: /config
# One mount for everything plMail writes. APP_STORAGE_DIR and
# APP_SECRETS_* point inside it, so attachments, raw messages,
# uploads and the generated secrets all land here — and every
# service sees the same files, which the encryption key depends on.
- type: bind
source: *path_data
target: /app/var/data
bind:
create_host_path: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Everything that is not the web server, in one container: the IMAP
# supervisor, one consumer per queue, the scheduler and the Mercure hub —
# ten processes, kept running by `app:work`. They used to be seven
# containers. Still one PROCESS per queue, so a send never waits behind a
# sync; only the containers merged.
#
# Answers to `mercure` on the network because it runs the hub, so the web
# container's proxy and MERCURE_URL reach it where they always reached the
# hub container.
worker:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console app:work
# Reaps the IMAP supervisor's children should it die hard.
init: true
# Longer than app:work's own 25s, which it gives every process to
# finish the message in hand.
stop_grace_period: 30s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker
networks:
default:
aliases: [mercure]
volumes:
# One mount for everything plMail writes, as on php.
- type: bind
source: *path_data
target: /app/var/data
bind:
create_host_path: true
# The hub's state, in the volumes the hub container used.
- type: volume
source: mercure_data
target: /data
- type: volume
source: mercure_config
target: /config
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
database:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_DB: app
POSTGRES_USER: app
# Only the _FILE form: the image rejects both being set at once, and
# the file is written with whatever db_password holds — generated or
# yours — so it is always the effective password.
POSTGRES_PASSWORD_FILE: /plmail/secrets/postgres_password
PGDATA: /plmail/postgres
healthcheck:
test: ["CMD", "pg_isready", "-d", "app", "-U", "app"]
# STATED, because the default is what made every start slow. Docker
# runs the first probe one `interval` after the container starts, and
# the default interval is thirty seconds — so the web server and the
# workers, which wait for this to be healthy, could not begin for
# half a minute however quickly Postgres was up, and for a whole
# minute whenever it was still replaying its log at the first look.
# A start that should take ten seconds took forty to seventy.
# pg_isready is a connection attempt; every five seconds costs nothing.
interval: 5s
timeout: 5s
# Thirty of them, so that a database in trouble is called unhealthy
# after the same two and a half minutes as before the interval changed.
retries: 30
start_period: 60s
depends_on:
secrets-init:
condition: service_completed_successfully
volumes:
# PGDATA is a subfolder of the shared mount, so Postgres creates
# and owns it on first start — nothing to chown by hand.
- type: bind
source: *path_data
target: /plmail
bind:
create_host_path: true
command:
- "postgres"
- "-c"
- "shared_preload_libraries=pg_stat_statements"
- "-c"
- "pg_stat_statements.track=top"
volumes:
caddy_data:
caddy_config:
mercure_data:
mercure_config: