mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Follow-up to eca0f8b7, which pointed our healthcheck at the hub's new
readiness path. Correct, and still the wrong shape: it leaves plMail asserting
something about the internals of an image that is deliberately unpinned so it
can update itself. The next time the hub moves that path, CI goes red for
another three days.
The image has always shipped its own HEALTHCHECK — wget against
127.0.0.1:2019/mercure/health/ready, 60s grace, 5s timeout, 5 retries — which
is the same grace and retries compose.yaml was spelling out by hand. Inheriting
it costs nothing there and cannot drift, because it travels with the image it
describes.
The test stack wanted to keep its five-second interval and inherit the command,
and Compose refuses: a healthcheck block without a `test` is rejected with
"must define a healthcheck command for all healthchecks". So owning the cadence
means owning the command again, which is the thing that rotted. It asks every
30s now — a few more seconds before a job starts, against never again waiting
for ever.
Verified against the current image: the running container carries the image's
healthcheck verbatim and reports healthy, and the test stack comes up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
398 lines
16 KiB
YAML
398 lines
16 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
|
|
|
|
# A whole-view mark-read or archive. On its own process for the reason the
|
|
# send queue is: somebody pressed a button and is watching an indicator, and
|
|
# this must not wait behind a backfill on `maintenance`. Drop this service and
|
|
# the indicator fills to nothing — the job is queued and never handled.
|
|
worker-bulk:
|
|
image: *app-image
|
|
restart: unless-stopped
|
|
command: php bin/console messenger:consume bulk --time-limit=3600 --memory-limit=256M
|
|
stop_grace_period: 15s
|
|
environment:
|
|
<<: *app-env
|
|
APP_CONTAINER_NAME: worker-bulk
|
|
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
|
|
# NO `test:` HERE ON PURPOSE — the image's own HEALTHCHECK is used.
|
|
#
|
|
# We used to spell one out, aimed at "/healthz", and the hub moved its
|
|
# readiness probe out from under it: "/healthz" answers 404 now, `curl
|
|
# -f` reads a 404 as failure, and this container reported itself unhealthy
|
|
# for ever while serving every subscription put to it. The image is
|
|
# deliberately on a floating tag so it keeps up to date by itself, which
|
|
# means anything we hard-code about its internals is a thing waiting to
|
|
# rot. Its own check — wget against 127.0.0.1:2019/mercure/health/ready,
|
|
# 60s grace, 5 retries — moves when the image moves, which is the whole
|
|
# point of not pinning it.
|
|
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:
|