Files
pl_mail/docs/install/configuration.md
T
Paul LütznerandClaude Opus 5.5 994c00b5cd What's new opens after an update, with a way to send feedback (#166)
* What's new opens after an update, with a way to send feedback

The release notes from CHANGELOG.md, read from the image, in a dialog that
opens by itself the first time somebody uses plMail after an update and
never on a fresh install. A ? in the top bar opens it again, with a dot
while something is unseen; "Don't show this after updates" silences it,
and Settings -> General -> What's new undoes that. Highlights are marked
in the changelog with an HTML comment and may carry a screenshot taken by
tests/e2e/whats-new-screenshots.spec.ts.

Send feedback is a mail from one of the person's own accounts to
APP_FEEDBACK_TO (feedback@pluetzner.de by default), with an optional
rating and a screenshot of the page behind the dialog.

Existing users are recorded as having seen 0.3.1 by a migration; anyone
created later starts on the release running at their first request.

Closes #165.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Plain changelog headlines from 0.3.2 on, and highlights with screenshots

Every bold headline users see under "New to you" now says what changed in
a few words. Each release gets a handful of highlights, eight of them
with a picture cropped to the card or pane they are about, taken by
tests/e2e/whats-new-screenshots.spec.ts.

Relative links in the notes keep their words and lose the target, and
the parsed changelog's cache key carries a format version so a new
image never serves the previous one's parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep highlight pictures for ten releases, and none that nothing names

Every file in public/whats-new ships in every image. ChangelogReaderTest
now fails on a picture no highlight names, and on one whose release is
more than ten back; the release pull request that crosses the line
deletes it and leaves the entry a highlight without a picture.
Documented in CONTRIBUTING.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Credit #166 in the changelog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Match the Back button by its whole name in the back-navigation specs

Playwright matches a string name as a substring, so "Back" also found the
new "Help and feedback" button in the top bar, which comes first, and the
specs opened its menu instead of going back to the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:17:57 +02:00

29 KiB

Configuration reference

Every environment variable plMail reads, what it does, what it defaults to, and what goes wrong when it is set to something else. The other pages in this section link here rather than repeating values, so this is the one to keep open while editing a compose file.

Where a value comes from

Four sources, highest precedence first:

  1. A real environment variable. Anything on the container's environment wins over everything below it, always. Nothing plMail generates is ever written over the top of a value somebody supplied.
  2. The generated secrets file, var/secrets/generated.env. Minted on first start and loaded both by the container entrypoint and by config/bootstrap_generated_secrets.php, which is pulled in through composer's autoload.files so that docker compose exec php php bin/console … — which bypasses the entrypoint entirely — sees the same values.
  3. .env.local, untracked. Useful when plMail runs from a checkout; there is no such file inside the published image.
  4. .env, committed, which is where the defaults in the tables below come from.

An empty value counts as absent at every level. That matters because docker compose passes ${APP_ENCRYPTION_KEY:-} through as an empty string when nobody set it, and treating that as "already configured" would defeat first-run generation.

The trap in that arrangement is docker compose's own use of .env: the file beside compose.yaml is what Compose reads to resolve ${VAR}, so a value put there becomes a real environment variable inside every container and switches off generation for it. That is why APP_SECRET, APP_ENCRYPTION_KEY, MERCURE_JWT_SECRET and APP_PUBLIC_URL are blank in the committed .env, under a banner saying so. Set them there only if you mean to manage them yourself.

Application variables

These are the variables in .env, in the order they appear there.

Variable What it does Default Required If it is wrong
APP_ENV Symfony environment. dev in .env; compose.yaml overrides it with ${APP_ENV:-prod} Yes dev on a real install boots the debug kernel and profiler, and switches off DefaultSecretsGuard, which only refuses shipped placeholder secrets in prod.
APP_SECRET Symfony's application secret. Signs remember-me cookies, among other things. blank — 32 random bytes, hex, generated on first start Yes, but generated Changing it invalidates every remember-me cookie, so everyone is logged out.
APP_SHARE_DIR Nothing. No code in this repository reads it. var/share No Nothing. Left in place because removing a variable is a change to .env; treat it as inert.
APP_SECRETS_FILE Path to the generated secrets file, relative to the project root unless it starts with /. var/secrets/generated.env Yes Point one service somewhere the others cannot see and it mints its own encryption key. EncryptionKeyProbe catches that at boot and refuses to start the server.
APP_STORAGE_DIR Root for everything plMail writes as a file: attachments/, raw/, uploads/ (avatars live under uploads/avatars/). Relative to the project root, or an absolute path inside it (/app/var/data is the same as var/data); one outside it is refused at start. var Yes Attachment paths are stored in the database relative to the project root, so changing this on an install that already holds mail orphans every existing file.
APP_PUBLIC_URL The address plMail is reached at from outside — what Google and Microsoft call back to. blank; asked for on the setup screen and written to the generated file. Changed afterwards under Admin → Address, which takes a restart to apply For push, yes Blank, http://, or a loopback host and calendar push and Graph mail push refuse to register at all. See Behind a reverse proxy.
APP_DEFAULT_TIMEZONE The clock a user who has never picked one in Settings is shown. Europe/Berlin (also the app.fallback_timezone parameter, used when the variable is absent) No An install left on the wrong zone shows every such user times that are plausibly wrong by their own offset. Timestamps are stored in UTC and only converted for display, so fixing it re-renders rather than rewrites. Not the container's PHP timezone, which is pinned to UTC in frankenphp/conf.d/10-app.ini.
DEFAULT_URI Base URI the router uses when generating URLs outside an HTTP request. http://localhost No Links in mail generated from a console command point at localhost. Push callbacks do not use this — they use APP_PUBLIC_URL.
DATABASE_URL Doctrine connection. postgresql://app@database:5432/app?serverVersion=18&charset=utf8 — deliberately credential-less Yes, but assembled A DSN that carries no password is treated as "nobody configured a database", and the generated POSTGRES_PASSWORD is spliced in. A DSN that carries one is taken as intent and left alone — so a wrong password here is used verbatim and Postgres refuses the connection. It is not blank because Doctrine reads the driver out of the scheme, and a blank DSN fails the image build's cache warmup with "could not find driver".
MESSENGER_TRANSPORT_DSN Transport behind the export, ingest, maintenance and async queues. doctrine://default?auto_setup=0 Yes auto_setup=0 is deliberate: the table is owned by a migration. Pointing this elsewhere without a consumer for each queue means mail queues and nothing drains it, with no error anywhere.
MAILER_DSN Symfony's mailer transport. null://null No Nothing today. plMail's own outgoing mail — reminder mails, iTIP replies, booking confirmations — deliberately does not go through it: it leaves through the user's own account via MailSenderRegistry, because plMail is a mail client and not a service with an outbound relay of its own. The setting is wired up and reached by nothing: MailerInterface is injected in no service in src/, so no code path uses this transport. The dev stack used to run a Mailpit container for it, which therefore never received a message and has been removed.
APP_ENCRYPTION_KEY Base64 32-byte libsodium secretbox key behind the encrypted_string Doctrine type — every mailbox password and OAuth token. blank — generated on first start Yes, but generated Lose it and the stored credentials are unrecoverable. A key that does not match stored data stops the web server from booting. See below.
APP_DEV_USER_EMAIL Default answer offered by app:setup's first prompt. blank No Nothing; the prompt is answerable by hand. The test stack sets it to seed a known user.
APP_DEV_USER_PASSWORD Default answer offered by app:setup's second prompt. blank No As above. Never set this on an install reachable by anyone else.
APP_DEMO_MODE Turns the whole instance into a public demo: every visitor to /demo is handed a throwaway user with a seeded mailbox, sending is swallowed and answered by a script, and a bar at the foot of the page delivers scripted mail on demand. Also switches off mail sync, push renewal, calendar sync and the forms that attach a real mailbox. 0 (and false when the variable is absent entirely) No On by accident on a real install and mail stops syncing, sending silently goes nowhere, and /demo hands a stranger a working session. Never set this on an instance holding real mail. See Demo mode.
APP_DEMO_TTL How long a demo visitor's throwaway user lives before app:demo:reap deletes it. An ISO 8601 duration. PT2H No An unparseable value falls back to two hours rather than throwing, so a typo shortens nothing and breaks nothing. Set it very long and the reaper stops keeping up with the visitors. Read only when APP_DEMO_MODE is on.
APP_DEMO_IMPRESSUM_NAME Operator named on the demo's legal notice (/impressum). blank For a public demo in Germany, yes Blank and the page renders a visible warning instead of a name. That is deliberate: § 5 TMG requires a real operator, and a notice naming nobody looks compliant from a distance without being so. Read only when APP_DEMO_MODE is on.
APP_DEMO_IMPRESSUM_ADDRESS Postal address on the same page. Newlines are preserved. blank As above As above.
APP_DEMO_IMPRESSUM_EMAIL Contact address on the same page, rendered as a mailto: link. blank As above As above.
APP_DEMO_PRIVACY_HOST The company operating the server, named in the demo's privacy notice (/datenschutz) as a processor. blank For a public demo, yes Blank and the page renders a visible warning instead of a name. Whoever runs the machine processes visitors' IP addresses on your behalf and has to be named; check you have a data processing agreement with them, because the notice says you do. Read only when APP_DEMO_MODE is on.
MERCURE_URL The hub address the app publishes to, inside the Docker network. http://mercure/.well-known/mercure Yes Wrong and nothing publishes: no live updates, no visible error on the page.
MERCURE_PUBLIC_URL The hub address the browser subscribes to. https://localhost/.well-known/mercure in both .env and compose.yaml Yes Wrong and the browser opens a stream to somewhere it cannot reach — mail lists stop updating by themselves while the rest of the app works. Derived from APP_PUBLIC_URL when this is unset, empty, or still the stock compose.yaml default. See Behind a reverse proxy.
MERCURE_JWT_SECRET Signs the publisher and subscriber JWTs. blank — 32 random bytes, hex, generated on first start Yes, but generated The app and the hub must hold the same value. They disagree and the hub rejects every subscriber, silently, from the browser's point of view.
MERCURE_COOKIE_NAME The name of the cookie that lets the browser subscribe. blank — plMail picks __Secure-mercure_access_token when APP_PUBLIC_URL is https and mercure_access_token otherwise, since a browser drops a __Secure- cookie that did not arrive over HTTPS No Set only to use a name of your own; the app sets it and the hub reads it, both from this one variable. The choice is made when the web container starts, so it follows a changed public address at the next restart.
MERCURE_UPSTREAM Where the web server proxies /.well-known/mercure to: the container running the hub, as host:port. mercure:80 No Only for a stack that does not call that container mercure. Wrong and the browser's stream answers 502 — the indicator in the top bar stays red while mail still arrives. MERCURE_URL has to name the same container.
GOOGLE_OAUTH_CLIENT_ID Google OAuth client. blank For Gmail Without it, "Sign in with Google" cannot start. A value stored under Admin → Integrations wins over this one. See Google.
GOOGLE_OAUTH_CLIENT_SECRET Google OAuth client secret. blank For Gmail As above.
GMAIL_PUBSUB_TOPIC Full topic name Gmail publishes watch notifications to. projects/your-project-id/topics/gmail-push — a placeholder, not a working value For instant Gmail The placeholder is not a topic you own, so every watch call fails. The project id is lowercase and often differs from the display name.
GMAIL_PUBSUB_VERIFICATION_TOKEN Shared secret appended to the Pub/Sub push endpoint as ?token=…. blank For instant Gmail Fails closed. With no token configured, POST /gmail/push refuses every notification with 403 rather than accepting unverified ones. A mismatch does the same.
MICROSOFT_OAUTH_CLIENT_ID Azure app registration client id. blank For Outlook Without it, "Sign in with Microsoft" cannot start. Also overridable under Admin → Integrations.
MICROSOFT_OAUTH_CLIENT_SECRET Azure client secret. blank For Outlook As above.
MICROSOFT_OAUTH_TENANT Which Microsoft accounts may sign in: common, organizations, consumers, or a tenant GUID. common No Must match the app registration's supported account types, or consent fails with AADSTS50194 — at consent time, not at setup. See Microsoft.
OIDC_ISSUER Issuer URL of an OpenID Connect provider (Authentik, Keycloak…) people may sign in through, exactly as its discovery document states it. blank No The fallback for Admin → SSO: this and the six below are read only while no complete provider is saved there, and a saved one wins whole. Blank, or any of the next two blank, and nothing saved, and the login page shows no single sign-on button. Not https:// and the button reports single sign-on as unavailable, because the ID token is trusted on the strength of TLS. See Single sign-on.
OIDC_CLIENT_ID Client id the provider issued to plMail. blank No Wrong and the provider refuses the sign-in on its own page.
OIDC_CLIENT_SECRET The matching client secret. blank No Wrong and the token exchange fails after the person has signed in at the provider; the log says so.
OIDC_BUTTON_LABEL The provider's name on the login button: "Sign in with Authentik". blank — "Sign in with single sign-on" No Cosmetic.
OIDC_SCOPES Space-separated scopes asked for. openid is always added. openid email profile No Without email, a first sign-in cannot find the account it belongs to.
OIDC_AUTO_CREATE Whether somebody the provider lets in, and plMail has never seen, gets a new ordinary account. false No On, everyone the provider admits to the application gets a mailbox client here — limit who that is at the provider. Off, an administrator creates each account first with the same address.
OIDC_REQUIRE_VERIFIED_EMAIL Link an existing account by address only when the provider says it verified that address. true No Only false, 0, no or off switch it off — blank or a typo leaves it on, so a mistake cannot open it. Off on a provider where people can edit their own address, and anybody can claim any account that has not signed in through the provider yet — administrators included.
APP_DB_LOG_LEVEL Minimum Monolog level kept in the database for the admin log browser. warning No The default — Admin → Logs can override it without a restart, and does so until it is set back to from the environment. Lowering it to info or debug fills the log_entry table quickly; app:monitoring:prune keeps 14 days by default, which is a lot of rows at debug.
APP_CONTAINER_NAME Which container a log row and a worker heartbeat are attributed to. web in .env; set per service in compose.yaml No Unset, the heartbeat key falls back to the hostname — which changes every time a container is recreated, so the admin panel accumulates dead workers until the stale sweep reaps them.
TRUSTED_PROXIES Proxies whose X-Forwarded-* headers Symfony believes. 127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 Behind a proxy, yes Too narrow and Symfony sees the proxy's address and http; too wide and a client can forge its own address. Both consequences are spelled out in Behind a reverse proxy.
JWT_SECRET_KEY Path to the private key the JMAP firewall's JWTs are signed with. jwt/private.pem in the directory APP_SECRETS_FILE is in Yes It lives beside the generated secrets on purpose, and follows them: the keys are not in the image, so a service with its own copy would reject tokens the others signed. Generated once by app:secrets:init.
JWT_PUBLIC_KEY Matching public key. jwt/public.pem in the directory APP_SECRETS_FILE is in Yes As above.
JWT_PASSPHRASE Passphrase for that keypair. blank No Must match the keypair on disk. Changing it without regenerating the keys makes every JWT unverifiable.
VAPID_SUBJECT Contact identifier sent with Web Push, per RFC 8292. Must be a mailto: or https: URL. mailto:admin@example.com For Web Push Some push services refuse a request whose subject is not a URL they accept.
VAPID_PUBLIC_KEY Web Push / JMAP PushSubscription public key. blank — generated by app:secrets:init Yes, but generated Browsers bind a subscription to the public key it was created with. Rotate it and every device silently stops receiving notifications until it re-subscribes.
VAPID_PRIVATE_KEY The matching private key. blank — generated Yes, but generated As above.
PUSH_ALLOWED_HOSTS Comma-separated hosts a push endpoint may point at although they resolve to a private address. blank Only for a LAN ntfy Every other private, loopback or link-local push endpoint is refused at registration and at send. Running the bundled ntfy on a LAN or Tailscale address, set it to your SERVER_NAME or phones stop receiving pushes. See the SSRF guard below.
INTEGRATIONS_ALLOW_HTTP Whether a user may name a http:// server for a self-hosted integration. false No See the SSRF guard below.
INTEGRATIONS_ALLOWED_HOSTS Comma-separated hosts exempt from the private-range block. blank No See the SSRF guard below.
APP_FEEDBACK_TO Where Send feedback in What's new mails to. The mail leaves through the person's own account; this is only the recipient. feedback@pluetzner.de (also the app.fallback_feedback_to parameter, used when the variable is absent) No Feedback from the people on this install goes to plMail's developer. Point it at your own address to read it yourself; a typo sends it nowhere, and the person who wrote it gets a bounce in their own mailbox.

The failure mode of this table is treating a blank as a missing value. Four of the blanks above are load-bearing: they are blank so that first-run generation happens, and filling one in with a value copied from somewhere else is how two services end up disagreeing about the encryption key.

Variables only the compose file and the entrypoint read

These never appear in .env — Compose substitutes them into compose.yaml, or the entrypoint reads them directly when it assembles DATABASE_URL.

Variable What it does Default If it is wrong
SERVER_NAME The hostname Caddy serves, inside the php container. localhost, :443, php:80 Caddy decides whether to terminate TLS from this. The default serves every name and address the machine is reached at with a self-signed certificate. :80 serves plain HTTP, which is what you want behind a reverse proxy. A hostname makes Caddy try to obtain a certificate for it, and is then the only name served.
HTTP_PORT Host port mapped to container port 80/tcp. 80 A port already in use stops the php container from starting.
HTTPS_PORT Host port mapped to container port 443/tcp. 443 As above.
HTTP3_PORT Host port mapped to container port 443/udp. 443 Only matters if you serve HTTP/3 directly.
POSTGRES_USER Database role, on both the database service and the assembled DSN. app Changing it after the database volume exists points the app at a role Postgres never created.
POSTGRES_DB Database name, same two places. app As above.
POSTGRES_VERSION Tag of the postgres image and serverVersion in the assembled DSN. 18 The image ships postgresql-client-18 for pg_dump, and pg_dump refuses to dump a server newer than itself — so a newer server breaks app:backup.
POSTGRES_PASSWORD Database password. blank — 24 random bytes, hex, generated on first start Never rotated by app:reset: Postgres was initialised with it and keeps its own copy, so a new one locks the app out of the database it just reset.
POSTGRES_HOST Host in the assembled DSN. Read by the entrypoint only. database Only relevant when running plMail outside this compose file.
POSTGRES_CHARSET charset in the assembled DSN. Entrypoint only. utf8 —
APP_SECRETS_DIR Directory generate-secrets writes into. /app/var/secrets Must agree with APP_SECRETS_FILE. The TrueNAS app moves both under one bind mount.
NTFY_PORT Host port for the optional ntfy container. 8090 Only used under the push profile.
NTFY_BASE_URL Base URL ntfy hands out in push endpoints. http://${SERVER_NAME:-localhost}:${NTFY_PORT:-8090} Baked into every endpoint handed out, so changing it later invalidates every existing subscription and every device must re-register.
NTFY_AUTH_DEFAULT_ACCESS ntfy's default access policy. read-write Open by design — UnifiedPush topics are unguessable and payloads are encrypted to the device — but set it to deny-all and create a user if this is exposed to the internet rather than reached over a VPN.
TEST_HTTP_PORT Host port for the test stack in compose.test.yaml. 8001 Development only.

The failure mode here is editing one half of a pair. POSTGRES_USER, POSTGRES_DB and POSTGRES_PASSWORD are read both by the database container at initialisation and by the app when it assembles its DSN, and the database only reads them the first time its volume is created.

What is generated, and where

frankenphp/generate-secrets.sh mints these into var/secrets/generated.env on first start, under a lock, only for names that are not already set:

Name How it is generated
APP_SECRET 32 bytes from /dev/urandom, hex
APP_ENCRYPTION_KEY 32 bytes, base64 — the size libsodium secretbox requires
POSTGRES_PASSWORD 24 bytes, hex, also written as the bare file postgres_password that the Postgres image reads through POSTGRES_PASSWORD_FILE
MERCURE_JWT_SECRET 32 bytes, hex

app:secrets:init adds the two that need PHP — a VAPID keypair and the lexik JWT keypair — after migrations have run, so there is one file to back up rather than several. APP_PUBLIC_URL is written into the same file by the setup screen.

Hex rather than base64 for three of the four is deliberate: POSTGRES_PASSWORD is spliced into a DSN and MERCURE_JWT_SECRET into a Caddy config, and neither wants +, / or =.

The failure mode is a service that cannot see the file. It mints its own copies, and from then on writes credentials the rest of the fleet cannot read.

The encryption key

APP_ENCRYPTION_KEY is the one value where a mistake is not recoverable. It is the libsodium key behind the encrypted_string Doctrine type, so every mailbox password and OAuth refresh token in the database is unreadable without it.

App\Infrastructure\Setup\EncryptionKeyProbe runs on every container start and tries to hydrate one account with stored credentials. When that fails it refuses to start the web server rather than let the container save accounts under a key half the fleet cannot read. Under a console invocation it warns and continues instead — because refusing there would block the very command that repairs the situation.

CONTRIBUTING has the full account of why the key is different and what to do when the keys disagree; the security model covers what the encryption does and does not protect. Backing it up is Backup and restore.

The failure mode is rotation under a running stack. The other services keep the old key in process memory until they restart, so for a while half of them cannot read what the other half writes.

The SSRF guard

Three variables relax one check, and it is worth being exact about what the check is for.

Self-hosted integrations — Nextcloud, Immich, Paperless-ngx — let a signed-in user type their own server address. That aims plMail's outbound HTTP client wherever the user likes, from inside a container network that also holds Postgres and the Mercure hub. App\Service\Integration\IntegrationUrlValidator resolves the host and refuses any address in a private, loopback, link-local or reserved range — Symfony's IpUtils::PRIVATE_SUBNETS, which includes IPv4-mapped IPv6 — and refuses http:// outright. Resolving is what catches container names such as database, wildcard DNS such as 127.0.0.1.nip.io, and spellings such as 127.1 or 2130706433.

That check gives the user a readable error when they save. The requests themselves go through App\Infrastructure\Http\UserUrlHttpClient, which wraps Symfony's NoPrivateNetworkHttpClient: it resolves the host again, pins the address it checked, and re-checks every redirect. The same client carries CalDAV, ICS feeds and push deliveries.

  • INTEGRATIONS_ALLOW_HTTP=true lets a user send an app password over plaintext HTTP. On a LAN that is often what people want, and it is off by default so that it stays a deliberate decision rather than an accident.

  • INTEGRATIONS_ALLOWED_HOSTS=nextcloud.lan,10.0.0.5 exempts named hosts from the private-range block. Every name you add is a name a user can point plMail at — so listing a whole range, or the host the database is on, hands an authenticated user a request from inside your network.

  • PUSH_ALLOWED_HOSTS=192.168.1.10 does the same for push endpoints, which are otherwise refused on a private address unconditionally. It exists for the bundled ntfy, whose endpoints are http://$SERVER_NAME:8090. Push deliveries never follow a redirect.

An administrator who pins baseUrl on the provider config under Admin → Integrations removes the surface entirely, and that is the better answer where it is available: the user's value is ignored, including a stale one saved before the pin. A pinned host is exempt from the private-range block without being listed, so pinning http://nextcloud for the container next door works.

The failure mode is an exemption that follows the first host only. A listed or pinned host may redirect within its own addresses; a public server redirecting to that host's address is still refused. A LAN service that redirects to a second internal name needs that name listed too.

Values configured in the database instead

Some settings are not environment variables at all, and a stored value wins over the environment when it is non-empty:

Setting Where
Google and Microsoft client id and secret Admin → Integrations
Microsoft tenant Admin → Integrations
Gmail Pub/Sub topic and verification token Admin → Integrations
Which file integrations are offered at all Admin → Integrations
Integration provider base URLs (the SSRF pin above) Admin → Integrations

This is the path the admin page documents, and the recommended one, because it shows the exact redirect URI to paste into the provider's console.

The failure mode is a half-filled row. A stored value only shadows the environment when it is actually set, so a row holding a client id and no secret does not silently disable a working .env — but a row holding both, entered once and forgotten, means editing the environment changes nothing.

Things that bite

A secret in .env is a secret in every container. Compose reads that file to resolve ${VAR}, which makes anything in it a real environment variable everywhere — and a real environment variable is exactly what tells the entrypoint "the operator supplied this, do not generate one". A value committed there would be shared by every plMail install in existence.

DATABASE_URL is not empty, and must not be. The committed value is a DSN with no credentials, because Doctrine reads the driver out of the scheme and a blank DSN has none — which fails the prod cache warmup during the image build with "could not find driver". The entrypoint's test is therefore "does this DSN carry a password", not "is it set". A DSN you supply with a password is taken as intent and left completely alone.

MERCURE_PUBLIC_URL follows APP_PUBLIC_URL unless you set it to something else. config/bootstrap_generated_secrets.php builds it from APP_PUBLIC_URL when it is unset, empty, or https://localhost/.well-known/mercure — the value compose.yaml sets on every install, which therefore says nothing about what you wanted. Any other value is taken as intent and left alone. An install with no public address stored keeps the localhost one.

GMAIL_PUBSUB_VERIFICATION_TOKEN blank means "reject everything", not "accept everything". That is the right default for an endpoint reachable from the internet, but it means an operator who sets up Pub/Sub and forgets the token sees notifications rejected with 403 and no mail arriving instantly — which looks exactly like a topic that was never created.

APP_STORAGE_DIR cannot be changed on a populated install. Attachment paths in the database are relative to the project root and include this prefix, so moving it orphans everything already stored. Move the contents to the new location at the same time, or leave it alone.

POSTGRES_VERSION is coupled to the image's pg_dump. The Dockerfile installs postgresql-client-18 from PGDG specifically because pg_dump refuses to dump a server newer than itself. Raise the server past 18 without rebuilding and app:backup fails at the moment somebody needs a backup.