mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
The stack was nine long-running containers: the web server, the database, the Mercure hub, and six of plMail's own image, one per background process. It is three now: php, worker and database, with secrets-init still running once and exiting. The worker runs `app:work`, a small supervisor keeping seven processes alive the way Docker's restart policy kept seven containers alive: the IMAP supervisor, one consumer per queue, the scheduler and the hub. A clean exit (the hourly recycle, the restart signal) comes straight back; a crash backs off from one second to thirty. Output passes through a whole line at a time, so two processes' JSON records never interleave, and SIGTERM reaches every child at once with 25s to finish. Each queue keeps a process of its own, which is the part the split was for: a consumer inside a long handler cannot take a send. And each process keeps its container's old name as APP_CONTAINER_NAME, so heartbeats, /healthz and the log filters are unchanged. The hub is the official Mercure 1.x binary, copied into the image and run as one of those processes. Not FrankenPHP's built-in hub, which is 0.24 and refuses the RFC 9068 tokens plMail has minted since v0.2.31. The worker answers to `mercure` on the network, so the Caddyfile proxy and MERCURE_URL reach it where they reached the hub container, and older compose files keep working with this image unchanged. It is configured from the application's own settings, including MERCURE_COOKIE_NAME, so a plain-HTTP install names the cookie once for both halves. A 5s shutdown grace stops it waiting on open subscriptions, which made every stop take the whole 25s. The dev override loses its per-worker blocks, one of which (worker-bulk) had been missing, so that queue ran the published image's code in development. The demo overlay runs app:work --without=imap-supervisor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
277 lines
12 KiB
YAML
277 lines
12 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}
|
|
# Blank on HTTPS, which is every real install. Serving plain HTTP, set it to a
|
|
# name without the `__Secure-` prefix (a browser drops such a cookie over
|
|
# HTTP). The app sets the cookie and the hub reads it, and both read this.
|
|
MERCURE_COOKIE_NAME: ${MERCURE_COOKIE_NAME:-}
|
|
|
|
# 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 reads its password when its container is created: it
|
|
# cannot wait for the app to hand it 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
|
|
|
|
# ── Everything that is not the web server ─────────────────────────────────
|
|
# One container, seven processes (app:work): the IMAP supervisor, one
|
|
# consumer per queue, the scheduler, and the Mercure hub. They used to be
|
|
# seven containers of this same image and the hub's own; see
|
|
# App\Service\System\Work\BackgroundProcesses for what each is for.
|
|
#
|
|
# Still one PROCESS per queue, which is the part that matters: a consumer
|
|
# already inside a long handler cannot pick up anything else, so pressing
|
|
# Send must never queue behind a Gmail batch. Only the containers merged.
|
|
#
|
|
# Answers to `mercure` on the network, because it runs the hub: the web
|
|
# container proxies /.well-known/mercure there and MERCURE_URL publishes
|
|
# there, exactly as they did to a hub container of that name.
|
|
worker:
|
|
image: *app-image
|
|
restart: unless-stopped
|
|
command: php bin/console app:work
|
|
# The IMAP supervisor's children are this container's grandchildren; if
|
|
# the supervisor dies hard, something has to reap them. Docker's own init.
|
|
init: true
|
|
# Longer than app:work's own 25s: it signals every process at once and
|
|
# gives them that long to finish the message in hand.
|
|
stop_grace_period: 30s
|
|
environment:
|
|
<<: *app-env
|
|
# Each process overrides this with its own name; this one is the
|
|
# supervisor's.
|
|
APP_CONTAINER_NAME: worker
|
|
networks:
|
|
default:
|
|
aliases: [mercure]
|
|
volumes:
|
|
# The hub's state, in the volumes the hub container used, so an upgrade
|
|
# keeps its event history.
|
|
- mercure_data:/data
|
|
- mercure_config:/config
|
|
# The x-app-storage list, spelled out for the reason php spells it out.
|
|
- app_secrets:/app/var/secrets
|
|
- app_attachments:/app/var/attachments
|
|
- app_raw:/app/var/raw
|
|
- app_uploads:/app/var/uploads
|
|
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"
|
|
|
|
# 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:
|