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 # PULLED ON EVERY `up`, which is the only thing that makes "unpinned" # actually mean "keeps itself current". # # Without this the tag floats and the image does not: compose reuses # whatever is already on the box, so a hub can sit years behind the tag it # claims to be while `docker compose up -d` reports success. v0.2.31 is what # that costs — plMail moved to the Mercure 1.0 protocol, every install whose # hub had not been pulled was left speaking 1.0 to a 0.x hub, and live # updates stopped with nothing in the application's own logs to say why. # # The cost of the check is one registry round trip per `up` that answers # "same digest" almost every time. The cost of not doing it is the paragraph # above. pull_policy: always 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:-} # The two halves of RFC 9068: the issuer this hub accepts, and the # audience it demands. Both are the literal from services.yaml — # `mercure.identifier` — which is where the reasoning lives. In short: a # hub is configured at container start, and an installation's own URL is # not always known by then, so these cannot be derived from it. # # resource_identifier has to be PINNED whatever value is chosen. Left # alone the hub works the audience out from each request, and plMail # arrives at it by two different roads — the browser subscribes at the # public URL, the application publishes to the internal one — so a hub # deriving its own answer would refuse every publish. MERCURE_TRUSTED_ISSUERS: https://plmail.invalid/.well-known/mercure MERCURE_EXTRA_DIRECTIVES: resource_identifier https://plmail.invalid/.well-known/mercure 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/ 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: