Files
pl_mail/compose.yaml
T
Paul LütznerandClaude Opus 5 8ed0314239 fix(docker): the documented install booted the prod image in dev mode
docker compose resolves ${VAR} against the .env file beside compose.yaml,
and the .env beside ours is Symfony's — committed, full of development
defaults, and never written with compose in mind. So APP_ENV: ${APP_ENV:-prod}
was not a default: .env says APP_ENV=dev, and that is what every operator who
ran `git clone && docker compose up -d` actually got.

The published image is built --no-dev and has no DebugBundle, which
bundles.php loads in dev. Every bin/console call died on the missing class
before it could do anything — including the entrypoint's database probe, which
then reported "The database is not up or not reachable" while the database sat
there healthy. Six services in a restart loop, and the message pointing at the
cause was not the one printed.

APP_ENV is hardcoded to prod here now. Running this stack in dev is what
compose.override.yaml.dist is for, and it still merges over the top.

Verified the way it broke: a fresh clone, `docker compose up -d`, nothing else,
against the published image — all eight containers healthy, APP_ENV=prod
inside, and the setup screen served.

ComposeEnvironmentTest holds every ${VAR} in the shipped compose files to the
rule this turned on: interpolate only what .env leaves alone, or leaves at
exactly the value written in the compose file. DATABASE_URL is the one
documented exemption. Text parsing rather than `docker compose config`, so it
fails in CI without a Docker daemon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:50:23 +02:00

371 lines
15 KiB
YAML

x-app-image: &app-image ghcr.io/karatektus/pl_mail:latest
# Every secret below is blank by default and generated on first start by the
# secrets-init service, into the shared app_secrets volume. Set any of them in
# your environment and yours is used instead — nothing is ever generated over
# the top of a value you supplied.
#
# "In your environment" and not "in a .env beside this file", because the .env
# beside this file is Symfony's — committed, full of development defaults, and
# read by compose for ${VAR} interpolation whether anyone meant it to be or not.
# A default written here only applies when that file is silent about the
# variable, so anything it does set is what a cloned repo actually runs with.
# ComposeEnvironmentTest holds every ${VAR} below to that: interpolate only what
# .env leaves alone, or leaves at exactly the value written here.
#
# APP_ENV is hardcoded for that reason. It was `${APP_ENV:-prod}` and .env says
# `APP_ENV=dev`, so every `git clone && docker compose up -d` — the install the
# README documents — booted the prod image in dev mode, where bundles.php loads
# DebugBundle, which `composer install --no-dev` never put in the image. Six
# services crash-looped on a missing class, and the entrypoint reported it as
# "the database is not up or not reachable". Running this stack in dev is what
# compose.override.yaml.dist is for; it sets APP_ENV per service and merges over
# this one.
#
# DATABASE_URL is deliberately absent: the app entrypoint assembles it from the
# generated POSTGRES_PASSWORD. Set it explicitly to point at a database of your
# own, and the entrypoint leaves it alone. It is the one exemption from the rule
# above — .env sets a credential-less DSN on purpose (a blank one has no driver
# and breaks the image's cache warmup), and the entrypoint tests for the missing
# password rather than for emptiness.
x-app-env: &app-env
APP_ENV: prod
APP_SECRET: ${APP_SECRET:-}
APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:-}
DATABASE_URL: ${DATABASE_URL:-}
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_VERSION: ${POSTGRES_VERSION:-18}
MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-doctrine://default?auto_setup=0}
MERCURE_URL: ${MERCURE_URL:-http://mercure/.well-known/mercure}
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL:-https://localhost/.well-known/mercure}
# The state that must outlive a container and be shared between them.
#
# Every app service mounts all four. app_secrets because the secrets minted on
# first run live there and a service that cannot see them generates its own and
# stops being able to read what the others wrote; the other three because the
# WORKERS write attachments, raw sources and staged uploads while the web
# container is what serves them back. Unmounted, each container keeps its own
# copy in its own layer: downloads 404 because the file is in a process that is
# not answering the request, and everything written vanishes on the next
# `docker compose up` that recreates a container.
#
# This has happened before. compose.override.yaml.dist was missing exactly these
# three mounts once and it cost real data — CONTRIBUTING.md records it — and
# they were then added to the override and to truenas.compose.yaml but never to
# the file the README actually tells an operator to run.
x-app-storage: &app-storage
- app_secrets:/app/var/secrets
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
services:
# Mints APP_SECRET, APP_ENCRYPTION_KEY, POSTGRES_PASSWORD and
# MERCURE_JWT_SECRET into app_secrets, then exits. Runs before everything
# else because Postgres and Mercure read their secrets when their containers
# are created — they cannot wait for the app to hand them one.
#
# Idempotent: on every later start it finds the file and does nothing.
secrets-init:
image: *app-image
restart: "no"
entrypoint: ["generate-secrets"]
environment:
APP_SECRET: ${APP_SECRET:-}
APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:-}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET:-}
volumes:
- app_secrets:/app/var/secrets
php:
image: *app-image
restart: unless-stopped
environment:
<<: *app-env
SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
MAILER_DSN: ${MAILER_DSN:-null://null}
APP_CONTAINER_NAME: web
volumes:
- caddy_data:/data
- caddy_config:/config
# The x-app-storage list, spelled out: this service adds Caddy's own two,
# and YAML merges mappings but not sequences, so there is no way to say
# "that anchor, plus these". Keep the four in step with it.
- app_secrets:/app/var/secrets
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
ports:
- target: 80
published: ${HTTP_PORT:-80}
protocol: tcp
- target: 443
published: ${HTTPS_PORT:-443}
protocol: tcp
- target: 443
published: ${HTTP3_PORT:-443}
protocol: udp
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
imap-supervisor:
image: *app-image
restart: unless-stopped
command: php bin/console app:imap:supervise
stop_grace_period: 30s
environment:
<<: *app-env
APP_CONTAINER_NAME: imap-supervisor
volumes: *app-storage
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, because a worker
# already inside a long handler cannot pick up anything else however the
# queues are prioritised. That was the whole problem: pressing Send behind a
# Gmail batch waited for the batch.
#
# Anything leaving plMail, and the only queue somebody is watching. Kept on
# its own process so a send is never behind a sync.
worker-export:
image: *app-image
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: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Mail arriving and the work that immediately follows it.
worker-ingest:
image: *app-image
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: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Bulk and deliberate: backfills, rule runs over existing mail, admin sweeps.
#
# Also drains `async`, the pre-split queue, so envelopes written before the
# upgrade still have a consumer. Nothing routes there any more — drop it from
# this command and from messenger.yaml once a release has passed.
worker-maintenance:
image: *app-image
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: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Fires the recurring jobs in App\Infrastructure\Scheduler\MaintenanceSchedule.
# Separate from messenger-worker so a long prune cannot stall the async queue.
scheduler:
image: *app-image
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-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
database:
image: postgres:${POSTGRES_VERSION:-18}-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_USER: ${POSTGRES_USER:-app}
# The official image supports _FILE for any of its variables, which is the
# only way to hand it a password that did not exist when compose parsed
# this file.
POSTGRES_PASSWORD_FILE: /run/plmail/postgres_password
depends_on:
secrets-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- database_data:/var/lib/postgresql
- app_secrets:/run/plmail:ro
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'
# Left empty on purpose: the entrypoint below fills them from the
# generated file. The hub image has no _FILE support, so sourcing it in a
# shell before exec'ing Caddy is the way in. Setting MERCURE_JWT_SECRET
# yourself still wins — the generator never overwrites it, so both sides
# read the same value.
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET:-}
entrypoint:
- /bin/sh
- -c
- |
if [ -z "$$MERCURE_JWT_SECRET" ] && [ -f /run/plmail/generated.env ]; then
MERCURE_JWT_SECRET="$$(sed -n 's/^MERCURE_JWT_SECRET=//p' /run/plmail/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. The dev one turns on
# `anonymous`, which accepts any subscriber without a JWT — and since
# frankenphp/Caddyfile proxies /.well-known/mercure* straight through, that
# let anyone reaching this instance subscribe to mail/user/<any id> and
# watch that account's sync activity. It also enables `demo`, `subscriptions`
# and `cors_origins *`. The default config requires the subscriber JWT the
# app now mints, and needs no CORS because the browser reaches the hub
# same-origin through our own Caddy.
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:
- mercure_data:/data
- mercure_config:/config
- app_secrets:/run/plmail:ro
# Push for Android, without Google.
#
# Optional and off by default: `docker compose --profile push up -d`. Nobody
# who does not want push pays for a container they never use.
#
# This needs no plMail code at all. The app already speaks Web Push — RFC 8030
# transport, RFC 8291 payload encryption, RFC 8292 VAPID signing — and ntfy is
# a push service that speaks exactly that. What it adds is the missing half on
# Android: RFC 8030 requires a *push service* that owns the endpoint URL and
# holds the connection to the device, browsers ship one and native apps do
# not. A UnifiedPush distributor on the phone talks to this.
#
# The alternative for an Android user is Firebase, which means Google sees
# that a message arrived and when. Running this instead keeps that on your own
# hardware, which is the reason to self-host mail in the first place.
ntfy:
image: binwiederhier/ntfy
profiles: [push]
restart: unless-stopped
command: serve
environment:
# Derived from SERVER_NAME, so push needs no configuration of its own:
# the host set at first boot is already the one phones can reach, which is
# the only thing this has to be. Set NTFY_BASE_URL explicitly to override
# it — a separate hostname, or TLS terminated in front (see below).
#
# It is baked into every endpoint handed out, so changing it later
# invalidates every existing subscription and every device must
# re-register. Worth getting right before handing the address to phones.
#
# ntfy cannot be hosted under a sub-path — it refuses a base-url with one
# at startup — so it cannot be folded behind the app's own Caddy at
# /push the way the Mercure hub is at /.well-known/mercure. Hence a port
# of its own.
NTFY_BASE_URL: ${NTFY_BASE_URL:-http://${SERVER_NAME:-localhost}:${NTFY_PORT:-8090}}
NTFY_LISTEN_HTTP: ':80'
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
# Open by default, and that is a considered choice rather than laziness.
# UnifiedPush topics are random and unguessable, and the payload is
# encrypted to the device's own key before it ever reaches here — so this
# service cannot read your mail, and someone who guessed a topic could
# only send noise the app fails to decrypt and discards.
#
# The default URL above is http, which is right for the LAN and Tailscale
# deployments this is usually reached over, and not for the open
# internet: the endpoint URL is itself the secret, so anyone able to watch
# the traffic could learn a topic. Facing the internet, put TLS in front
# and set NTFY_BASE_URL to the https address.
#
# To lock it down anyway, set this to deny-all and create a user:
# docker compose exec ntfy ntfy user add --role=admin plmail
# then give the phone that login in the ntfy app. It is the right move if
# this is exposed to the internet rather than reached over a VPN.
NTFY_AUTH_DEFAULT_ACCESS: ${NTFY_AUTH_DEFAULT_ACCESS:-read-write}
# A personal server has a handful of devices; the defaults are sized for a
# public instance and would let one client exhaust it.
NTFY_VISITOR_REQUEST_LIMIT_BURST: '60'
NTFY_VISITOR_REQUEST_LIMIT_REPLENISH: '5s'
ports:
- "${NTFY_PORT:-8090}:80"
volumes:
- ntfy_data:/var/lib/ntfy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/v1/health"]
timeout: 5s
retries: 5
start_period: 20s
volumes:
app_secrets:
app_attachments:
app_raw:
app_uploads:
caddy_data:
caddy_config:
database_data:
mercure_data:
mercure_config:
ntfy_data: