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