Files
pl_mail/truenas.compose.yaml
T
Paul LütznerandClaude Opus 5 31db1ff16d Put the image tag first in the TrueNAS optional settings
Which builds an install receives is the one optional setting worth
deciding on rather than leaving, and it was third down the block behind a
one-line comment. It goes first now, with what each tag actually means:
:latest for releases, :main for every merge — which is how to follow the
work without waiting for a version to be tagged — and pinned forms for
when neither is wanted.

Two things the old comment did not say. The image tag has no leading v
even though the git tag does, and downgrading is not supported, because
migrations run forward on boot and moving back down leaves the database
ahead of the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:48:04 +02:00

464 lines
21 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 and Mercure read their secrets when their
# containers are created and cannot wait for the app to hand them 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 999 in the 18-alpine image (uid 70 before 18) — 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 999:999 /app/var/data/postgres
# Postgres re-execs as uid 999 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 999. Execute-only: grants
# traversal, not the ability to list or read anything.
chmod o+x /app/var/data || setfacl -m u:999:--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
imap-supervisor:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console app:imap:supervise
stop_grace_period: 30s
environment:
<<: *app-env
APP_CONTAINER_NAME: imap-supervisor
volumes:
# 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
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# ── Workers, one per queue ────────────────────────────────────────────
# Three processes rather than three transports on one: a worker already
# inside a long handler cannot pick up anything else however the queues are
# prioritised, which is why pressing Send used to wait for a Gmail batch.
#
# Anything leaving plMail, and the only queue somebody is watching.
worker-export:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console messenger:consume export --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-export
volumes:
# 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
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Runs the scheduled jobs defined in
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
# renewal, monitoring pruning and the blob sweep. Drop this service and
# none of them ever fire.
# Mail arriving and the work that immediately follows it.
worker-ingest:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console messenger:consume ingest --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-ingest
volumes:
# 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
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Runs the scheduled jobs defined in
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
# renewal, monitoring pruning and the blob sweep. Drop this service and
# none of them ever fire.
# Bulk and deliberate. Also drains `async`, the pre-split queue, so
# envelopes written before the upgrade still have a consumer. Nothing routes
# there any more; drop it once a release has passed.
worker-maintenance:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console messenger:consume maintenance async --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-maintenance
volumes:
# 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
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Runs the scheduled jobs defined in
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
# renewal, monitoring pruning and the blob sweep. Drop this service and
# none of them ever fire.
scheduler:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console messenger:consume scheduler_default --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: scheduler
volumes:
# app:prune:blobs unlinks files across all three stores, so it
# needs the same binds as the workers that write them.
- type: bind
source: *path_data
target: /app/var/data
bind:
create_host_path: true
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"]
timeout: 5s
retries: 5
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"
mercure:
image: dunglas/mercure
restart: unless-stopped
environment:
SERVER_NAME: ':80'
# The hub image has no _FILE support, so the entrypoint below reads
# the generated secret in a shell before exec'ing Caddy. Filling in
# mercure_jwt above wins: the generator never overwrites it, so both
# sides still read the same value.
MERCURE_JWT_SECRET: *mercure_jwt
entrypoint:
- /bin/sh
- -c
- |
if [ -z "$$MERCURE_JWT_SECRET" ] && [ -f /plmail/secrets/generated.env ]; then
MERCURE_JWT_SECRET="$$(sed -n 's/^MERCURE_JWT_SECRET=//p' /plmail/secrets/generated.env)"
fi
export MERCURE_PUBLISHER_JWT_KEY="$$MERCURE_JWT_SECRET"
export MERCURE_SUBSCRIBER_JWT_KEY="$$MERCURE_JWT_SECRET"
exec "$$@"
- --
# The image's default Caddyfile, NOT dev.Caddyfile — this is a
# production stack and the dev one turns on `anonymous`, which accepts
# subscribers with no JWT at all. Since the app proxies
# /.well-known/mercure* publicly, that let anyone who could reach this
# instance subscribe to mail/user/<any id> and watch that account sync.
# The default config requires the subscriber JWT the app now mints.
command: /usr/bin/caddy run --config /etc/caddy/Caddyfile
depends_on:
secrets-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/healthz"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- type: bind
source: *path_data
target: /plmail
bind:
create_host_path: true
read_only: true
- type: volume
source: mercure_data
target: /data
- type: volume
source: mercure_config
target: /config
volumes:
caddy_data:
caddy_config:
mercure_data:
mercure_config: