mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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>
315 lines
15 KiB
YAML
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:
|