Open a fresh install from the LAN, not only from the machine it runs on

The stock compose file served https://localhost and nothing else. Reached
by address or hostname, Caddy had no certificate to offer and ended the
handshake, which a browser reports as an SSL protocol error with no way
to proceed. A headless server could not be set up without first setting
SERVER_NAME.

The default now answers every name and address with the self-signed
localhost certificate (default_sni/fallback_sni, and `:443` in the default
SERVER_NAME), so the browser shows its usual warning.

Live updates follow: compose.yaml's https://localhost hub address is
treated as the placeholder it is and gives way to the public address
stored during setup. Any other MERCURE_PUBLIC_URL, and any SERVER_NAME an
operator sets, is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-09 15:58:54 +02:00
1 parent 34f6ec5951
commit a8aea33874
12 files changed
+141 -47

No files matched your search

+10
View File
@@ -8,6 +8,16 @@ The published image tags: `latest` follows the most recent release below,
## Unreleased
**Fixed: a fresh install could only be opened on the machine it runs on.** The stock `compose.yaml`
served `https://localhost` and nothing else. Opened from another machine by address or hostname, the
browser reported an SSL protocol error, which unlike a certificate warning cannot be clicked
through, so a headless server could not be set up at all without first setting `SERVER_NAME`. The
default now answers on every name and address with the same self-signed certificate, and the
browser shows its usual warning. Live updates follow as well: the hub address is taken from the
public address chosen during setup, where the stock file used to pin it to `localhost`. An install
that sets `SERVER_NAME` or `MERCURE_PUBLIC_URL` itself is unchanged. See
[Docker → Starting it](docs/install/docker.md#starting-it).
**Mail can be marked as spam with a button.** It sits beside Delete: among a row's buttons, in an
open conversation's toolbar and above a selection. On one conversation it opens a short menu — move
it to Spam, or move it and also create a filter that sends everything from this sender, or from the
+3 -1
View File
@@ -108,7 +108,9 @@ cd pl_mail
docker compose up -d
```
Open [https://localhost](https://localhost) and create your account.
Open [https://localhost](https://localhost) and create your account. From another machine, open
`https://` and the server's address instead; the browser warns about the self-signed certificate
once and lets you continue.
### TrueNAS
+7 -1
View File
@@ -89,7 +89,13 @@ services:
restart: unless-stopped
environment:
<<: *app-env
SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
# `localhost` is the name Caddy makes its self-signed certificate for.
# `:443` is every other name and address the machine is reached at: the
# same site, answered with that same certificate (frankenphp/Caddyfile
# says how), so an install on a headless box opens from the LAN behind
# the usual certificate warning. Both go when you set SERVER_NAME — a
# real domain is then the only name served, and ":80" is plain HTTP.
SERVER_NAME: ${SERVER_NAME:-localhost, :443}, php:80
MAILER_DSN: ${MAILER_DSN:-null://null}
APP_CONTAINER_NAME: web
volumes:
+16 -2
View File
@@ -117,10 +117,24 @@ declare(strict_types=1);
// Derived here rather than configured: it is one less thing to fill in, and
// it cannot drift from the public URL. An explicit MERCURE_PUBLIC_URL still
// wins, for the install whose hub really does live somewhere else.
//
// "Is it set" is not quite the test, for the reason it is not for the
// database URL above: on the stock compose file it is always set.
// compose.yaml defaults it to https://localhost/.well-known/mercure, which
// is right for the install opened at https://localhost and for no other —
// one set up from another machine on the LAN told every browser to
// subscribe on its own loopback, and the lists never refreshed. That one
// value is the placeholder nobody chose, so a public address stored during
// setup replaces it. With no address stored it stays, and an install from
// before setup asked the question keeps working where it always did.
$publicUrl = trim((string) ($_SERVER['APP_PUBLIC_URL'] ?? $_ENV['APP_PUBLIC_URL'] ?? ''));
$hubUrl = trim((string) ($_SERVER['MERCURE_PUBLIC_URL'] ?? $_ENV['MERCURE_PUBLIC_URL'] ?? ''));
if (false === $isSet('MERCURE_PUBLIC_URL') && '' !== $publicUrl) {
$put('MERCURE_PUBLIC_URL', rtrim($publicUrl, '/').'/.well-known/mercure');
if ('' !== $publicUrl && ('' === $hubUrl || 'https://localhost/.well-known/mercure' === $hubUrl)) {
$derived = rtrim($publicUrl, '/').'/.well-known/mercure';
$_SERVER['MERCURE_PUBLIC_URL'] = $derived;
$_ENV['MERCURE_PUBLIC_URL'] = $derived;
}
// The subscriber cookie takes the `__Secure-` prefix where a browser will
+9 -8
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/configuration.md sha1:cb3491e232974eac33e32f1e9ef2fd9298c8a108 -->
<!-- translated-from: install/configuration.md sha1:71e9bc427171913eea3f957a3abc1d2b01c32940 -->
# Konfigurationsreferenz
Jede Umgebungsvariable, die plMail liest, was sie bewirkt, welchen Vorgabewert sie hat und was
@@ -60,7 +60,7 @@ Das sind die Variablen aus `.env`, in der Reihenfolge, in der sie dort stehen.
| `APP_DEMO_IMPRESSUM_EMAIL` | Kontaktadresse auf derselben Seite, als `mailto:`-Link. | leer | Wie oben | Wie oben. |
| `APP_DEMO_PRIVACY_HOST` | Das Unternehmen, das den Server betreibt; wird in der Datenschutzerklärung der Demo (`/datenschutz`) als Auftragsverarbeiter genannt. | leer | Für eine öffentliche Demo: ja | Leer, und die Seite zeigt statt eines Namens eine sichtbare Warnung. Wer die Maschine betreibt, verarbeitet in deinem Auftrag die IP-Adressen der Besucher und muss genannt werden; prüfe, ob ein Auftragsverarbeitungsvertrag besteht — die Seite sagt, dass es einen gibt. Wird nur gelesen, wenn `APP_DEMO_MODE` an ist. |
| `MERCURE_URL` | Die Hub-Adresse, an die die **Anwendung** innerhalb des Docker-Netzes veröffentlicht. | `http://mercure/.well-known/mercure` | Ja | Falsch, und nichts wird veröffentlicht: keine Live-Aktualisierungen, kein sichtbarer Fehler auf der Seite. |
| `MERCURE_PUBLIC_URL` | Die Hub-Adresse, die der **Browser** abonniert. | `https://localhost/.well-known/mercure`, sowohl in `.env` als auch in `compose.yaml` | Ja | Falsch, und der Browser öffnet einen Stream dorthin, wo er nicht hinkommt — Maillisten hören auf, sich selbst zu aktualisieren, während der Rest der Anwendung funktioniert. Wird nur dann aus `APP_PUBLIC_URL` abgeleitet, wenn sie nicht gesetzt oder leer ist, was die Standard-`compose.yaml` verhindert. Siehe [Hinter einem Reverse-Proxy](reverse-proxy.md). |
| `MERCURE_PUBLIC_URL` | Die Hub-Adresse, die der **Browser** abonniert. | `https://localhost/.well-known/mercure`, sowohl in `.env` als auch in `compose.yaml` | Ja | Falsch, und der Browser öffnet einen Stream dorthin, wo er nicht hinkommt — Maillisten hören auf, sich selbst zu aktualisieren, während der Rest der Anwendung funktioniert. Wird aus `APP_PUBLIC_URL` abgeleitet, wenn sie nicht gesetzt, leer oder noch der Vorgabewert der Standard-`compose.yaml` ist. Siehe [Hinter einem Reverse-Proxy](reverse-proxy.md). |
| `MERCURE_JWT_SECRET` | Signiert die Publisher- und Subscriber-JWTs. | leer — 32 zufällige Bytes, hexadezimal, beim ersten Start erzeugt | Ja, aber erzeugt | Anwendung und Hub müssen denselben Wert halten. Weichen sie voneinander ab, weist der Hub jeden Subscriber ab — aus Sicht des Browsers lautlos. |
| `MERCURE_COOKIE_NAME` | Der Name des Cookies, mit dem der Browser abonnieren darf. | leer — plMail wählt `__Secure-mercure_access_token`, wenn `APP_PUBLIC_URL` https ist, und sonst `mercure_access_token`, weil ein Browser ein `__Secure-`-Cookie verwirft, das nicht über HTTPS kam | Nein | Nur setzen, wenn du einen eigenen Namen willst; die Anwendung setzt das Cookie und der Hub liest es, beide aus dieser einen Variablen. Gewählt wird beim Start des Web-Containers, eine geänderte öffentliche Adresse wirkt also ab dem nächsten Neustart. |
| `MERCURE_UPSTREAM` | Wohin der Webserver `/.well-known/mercure` weiterreicht: der Container, in dem der Hub läuft, als `host:port`. | `mercure:80` | Nein | Nur für einen Stack, der diesen Container nicht `mercure` nennt. Falsch, und der Stream des Browsers antwortet mit 502 — die Anzeige in der oberen Leiste bleibt rot, während Mail weiter ankommt. `MERCURE_URL` muss denselben Container nennen. |
@@ -96,7 +96,7 @@ sie direkt, wenn er `DATABASE_URL` zusammensetzt.
| Variable | Was sie bewirkt | Vorgabe | Wenn sie falsch ist |
|---|---|---|---|
| `SERVER_NAME` | Der Hostname, den Caddy im `php`-Container bedient. | `localhost, php:80` | Caddy entscheidet daran, ob es TLS terminiert. `:80` liefert schlichtes HTTP aus, was du hinter einem Reverse-Proxy willst. Ein Hostname bringt Caddy dazu, ein Zertifikat dafür zu beschaffen. |
| `SERVER_NAME` | Der Hostname, den Caddy im `php`-Container bedient. | `localhost, :443, php:80` | Caddy entscheidet daran, ob es TLS terminiert. Der Vorgabewert bedient jeden Namen und jede Adresse, unter der die Maschine erreicht wird, mit einem selbstsignierten Zertifikat. `:80` liefert schlichtes HTTP aus, was du hinter einem Reverse-Proxy willst. Ein Hostname bringt Caddy dazu, ein Zertifikat dafür zu beschaffen, und ist dann der einzige Name, der bedient wird. |
| `HTTP_PORT` | Host-Port, der auf Container-Port 80/tcp abgebildet wird. | `80` | Ein bereits belegter Port hindert den `php`-Container am Start. |
| `HTTPS_PORT` | Host-Port, der auf Container-Port 443/tcp abgebildet wird. | `443` | Wie oben. |
| `HTTP3_PORT` | Host-Port, der auf Container-Port 443/udp abgebildet wird. | `443` | Nur relevant, wenn du HTTP/3 direkt ausliefern willst. |
@@ -239,11 +239,12 @@ Prüfung des Entrypoints lautet deshalb "trägt diese DSN ein Passwort?" und nic
Eine DSN, die du *mit* Passwort mitgibst, wird als Absicht gewertet und vollständig in Ruhe
gelassen.
**`MERCURE_PUBLIC_URL` hat einen Vorgabewert, der nicht abgeleitet ist.**
`config/bootstrap_generated_secrets.php` baut ihn aus `APP_PUBLIC_URL` — aber nur, wenn er nicht
gesetzt oder leer ist, und `compose.yaml` setzt ihn bedingungslos auf
`https://localhost/.well-known/mercure`. Auf jeder Installation, die nicht unter `https://localhost`
erreicht wird, setze ihn ausdrücklich.
**`MERCURE_PUBLIC_URL` folgt `APP_PUBLIC_URL`, solange du sie nicht auf etwas anderes setzt.**
`config/bootstrap_generated_secrets.php` baut sie aus `APP_PUBLIC_URL`, wenn sie nicht gesetzt, leer
oder `https://localhost/.well-known/mercure` ist — der Wert, den `compose.yaml` auf jeder
Installation setzt und der deshalb nichts darüber sagt, was du wolltest. Jeder andere Wert gilt als
Absicht und bleibt unangetastet. Eine Installation ohne gespeicherte öffentliche Adresse behält den
`localhost`-Wert.
**Ein leeres `GMAIL_PUBSUB_VERIFICATION_TOKEN` bedeutet "alles ablehnen", nicht "alles annehmen".**
Für einen aus dem Internet erreichbaren Endpunkt ist das die richtige Vorgabe, aber es bedeutet:
+10 -2
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/docker.md sha1:c5abef947d51031887eb536d6c09b3784406aa96 -->
<!-- translated-from: install/docker.md sha1:6a1b5ac4bb8a1c96bf13c943ef14a18ab8ce0cbf -->
# Installation mit Docker Compose
Der unterstützte Weg, von Anfang bis Ende: was du brauchst, was `docker compose up` tatsächlich tut,
@@ -57,6 +57,12 @@ docker compose up -d
Öffne dann [https://localhost](https://localhost).
Auf einer Maschine ohne eigenen Browser öffnest du von einer anderen aus `https://` und die Adresse
oder den Hostnamen dieser Maschine — etwa `https://192.168.1.20`. In beiden Fällen hat der Container
das Zertifikat selbst signiert; der Browser warnt also einmal und lässt dich weitermachen. Für ein
Zertifikat, dem Browser vertrauen, setz `SERVER_NAME` auf eine Domain, die auf diese Maschine zeigt,
oder stell einen [Reverse-Proxy](reverse-proxy.md) davor.
**Schließe die Einrichtung ab, bevor du irgendetwas anderes machst.** Solange es noch keinen Nutzer
gibt, steht `/install` jedem offen, der die Instanz erreicht — genau so legst du dein eigenes Konto
an, und genau so wird jemand anderes zum Administrator, wenn er schneller da ist. Das ist der
@@ -85,7 +91,9 @@ die du kennen solltest:
anschließend ein VAPID-Schlüsselpaar sowie das JMAP-JWT-Schlüsselpaar, falls diese fehlen.
5. **Caddy nimmt den Betrieb auf.** Der `php`-Container veröffentlicht 80/tcp, 443/tcp und 443/udp —
umlegbar über `HTTP_PORT`, `HTTPS_PORT` und `HTTP3_PORT` — und bedient die Namen aus
`SERVER_NAME`, das `compose.yaml` auf `localhost, php:80` setzt. Außerdem leitet er
`SERVER_NAME`, das `compose.yaml` auf `localhost, :443, php:80` setzt: `localhost` ist der Name,
für den das selbstsignierte Zertifikat ausgestellt wird, und `:443` beantwortet jeden anderen
Namen und jede andere Adresse mit demselben Zertifikat. Außerdem leitet er
`/.well-known/mercure*` an den Hub-Container weiter, sodass der Browser den Hub auf derselben
Origin erreicht und kein CORS nötig ist.
+14 -14
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/reverse-proxy.md sha1:0c7b41ef1eee378b74f5810cd9189eab728c4a1a -->
<!-- translated-from: install/reverse-proxy.md sha1:72aecc70f1035f186e2322b3152b8dd4e0ad00f4 -->
# Hinter einem Reverse-Proxy
Alles, was von außerhalb des eigenen Netzes erreichbar ist, will ein echtes Zertifikat davor, und
@@ -21,7 +21,7 @@ services:
SERVER_NAME: ":80" # FrankenPHP serves plain HTTP; no certificate here
```
`SERVER_NAME` steht standardmäßig auf `localhost, php:80`, und Caddy entscheidet im Container anhand
`SERVER_NAME` steht standardmäßig auf `localhost, :443, php:80`, und Caddy entscheidet im Container anhand
dieses Namens, ob es TLS selbst terminiert. `:80` sagt ihm, dass es das nicht tun soll. Veröffentliche
dann nur den HTTP-Port — `HTTP_PORT` bildet den Host-Port auf den Container-Port 80 ab — und lass
den Proxy ihn erreichen. `truenas.compose.yaml` macht genau das und veröffentlicht `30080`.
@@ -35,8 +35,8 @@ MERCURE_PUBLIC_URL=https://mail.example.com/.well-known/mercure
```
**Der typische Fehlerfall ist, `SERVER_NAME` hinter einem Proxy auf dem Vorgabewert zu belassen.**
Caddy versucht dann, `localhost` auszuliefern, während der Proxy es über Containernamen oder
IP-Adresse anspricht — und die Anfrage passt auf keinen Site-Block.
Caddy beantwortet die schlichte HTTP-Anfrage des Proxys dann mit einer Weiterleitung auf HTTPS, und
der Browser dreht sich im Kreis.
## `APP_PUBLIC_URL`
@@ -121,11 +121,11 @@ Zwei Variablen, und sie zeigen in entgegengesetzte Richtungen:
Adresse plus `/.well-known/mercure` sein.
`config/bootstrap_generated_secrets.php` leitet die zweite aus `APP_PUBLIC_URL` ab, damit es eine
Sache weniger auszufüllen gibt — aber **nur, wenn `MERCURE_PUBLIC_URL` nicht gesetzt oder leer
ist**, und die Standard-`compose.yaml` setzt sie bedingungslos auf
`https://localhost/.well-known/mercure`. Auf einer Installation hinter einem Proxy setze sie also
ausdrücklich. `truenas.compose.yaml` lässt den eigenen Wert genau deshalb leer, damit die Ableitung
stattfinden kann.
Sache weniger auszufüllen gibt. Das geschieht, wenn `MERCURE_PUBLIC_URL` nicht gesetzt, leer oder
noch `https://localhost/.well-known/mercure` ist, der Wert, den die Standard-`compose.yaml` auf
jeder Installation setzt. Jeder andere Wert ist deiner und bleibt unangetastet. Eine Installation
hinter einem Proxy braucht also `APP_PUBLIC_URL` und sonst nichts — aus der Umgebung wie oben oder
aus dem Einrichtungsbildschirm.
Nicht nur der Host zählt, sondern auch das Schema: Symfony leitet das `secure`-Flag des
Subscriber-Cookies aus dieser URL ab, ein `https`-Wert auf einer Installation mit schlichtem HTTP
@@ -239,11 +239,11 @@ Anbieter als Zustellfehler wertet und mit Backoff wiederholt.
## Fallstricke
**`MERCURE_PUBLIC_URL` wird bei der Standard-Compose-Datei nicht abgeleitet.** Die Ableitung aus
`APP_PUBLIC_URL` findet nur statt, wenn die Variable nicht gesetzt oder leer ist, und `compose.yaml`
setzt sie auf `https://localhost/.well-known/mercure`. Das ist der mit Abstand wahrscheinlichste
Grund dafür, dass Live-Aktualisierungen in der Entwicklung funktionieren und in der Produktion
nicht.
**`MERCURE_PUBLIC_URL` wird erst abgeleitet, wenn es eine öffentliche Adresse gibt.** Ohne
`APP_PUBLIC_URL` in der Umgebung und ohne eine vom Einrichtungsbildschirm gespeicherte bleibt das
`https://localhost/.well-known/mercure` der Standard-Compose-Datei stehen, und Live-Aktualisierungen
funktionieren unter `https://localhost` und nirgends sonst. Admin → Adresse zeigt, was gespeichert
ist.
**Eine fehlgeschlagene Push-Registrierung ist kein Fehlerzustand.** Die Registrierung wird stündlich
von `app:calendar:push` wiederholt, statt an den Klick gebunden zu sein, mit dem ein Kalender
+7 -6
View File
@@ -56,7 +56,7 @@ These are the variables in `.env`, in the order they appear there.
| `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` only when this is unset or empty, which the stock `compose.yaml` prevents. See [Behind a reverse proxy](reverse-proxy.md). |
| `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](reverse-proxy.md). |
| `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. |
@@ -92,7 +92,7 @@ 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, php:80` | Caddy decides whether to terminate TLS from this. `:80` serves plain HTTP, which is what you want behind a reverse proxy. A hostname makes Caddy try to obtain a certificate for it. |
| `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. |
@@ -227,10 +227,11 @@ cache warmup during the image build with "could not find driver". The entrypoint
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` has a default that is not derived.** `config/bootstrap_generated_secrets.php`
builds it from `APP_PUBLIC_URL` — but only when it is unset or empty, and `compose.yaml` sets it to
`https://localhost/.well-known/mercure` unconditionally. On any install not reached at
`https://localhost`, set it explicitly.
**`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
+9 -1
View File
@@ -52,6 +52,12 @@ docker compose up -d
Then open [https://localhost](https://localhost).
On a machine with no browser of its own, open `https://` and that machine's address or hostname
from another one — `https://192.168.1.20`, say. Either way the certificate is one the container
signed for itself, so the browser warns once and lets you continue. To serve a certificate browsers
trust, set `SERVER_NAME` to a domain that resolves to this machine, or put a
[reverse proxy](reverse-proxy.md) in front.
**Finish the setup screen before you go anywhere else.** Until the first user exists, `/install` is
open to whoever reaches it — that is how you create your own account, and it means anybody who
reaches the instance first becomes its administrator instead. This is the usual bargain for
@@ -77,7 +83,9 @@ knowing about:
if they are missing.
5. **Caddy serves.** The `php` container publishes 80/tcp, 443/tcp and 443/udp — remappable with
`HTTP_PORT`, `HTTPS_PORT` and `HTTP3_PORT` — and serves the names in `SERVER_NAME`, which
`compose.yaml` sets to `localhost, php:80`. It also proxies `/.well-known/mercure*` to the hub
`compose.yaml` sets to `localhost, :443, php:80`: `localhost` is the name the self-signed
certificate is made for, and `:443` answers every other name and address with that same
certificate. It also proxies `/.well-known/mercure*` to the hub
container, so the browser reaches the hub same-origin and needs no CORS.
`docker compose up -d --wait` blocks until the healthchecks pass, which is the better form in a
+11 -12
View File
@@ -19,7 +19,7 @@ services:
SERVER_NAME: ":80" # FrankenPHP serves plain HTTP; no certificate here
```
`SERVER_NAME` defaults to `localhost, php:80`, and Caddy inside the container decides from that name
`SERVER_NAME` defaults to `localhost, :443, php:80`, and Caddy inside the container decides from that name
whether to terminate TLS itself. `:80` tells it not to. Publish only the HTTP port then — `HTTP_PORT`
maps the host port to container port 80 — and let the proxy reach it. `truenas.compose.yaml` does
exactly this and publishes `30080`.
@@ -32,9 +32,8 @@ TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
MERCURE_PUBLIC_URL=https://mail.example.com/.well-known/mercure
```
**The failure mode is leaving `SERVER_NAME` at its default behind a proxy.** Caddy then tries to
serve `localhost` while the proxy addresses it by container name or IP, and the request does not
match a site block.
**The failure mode is leaving `SERVER_NAME` at its default behind a proxy.** Caddy then
answers the proxy's plain-HTTP request with a redirect to HTTPS, and the browser goes round in a loop.
## `APP_PUBLIC_URL`
@@ -113,10 +112,10 @@ Two variables, and they point in opposite directions:
`/.well-known/mercure`.
`config/bootstrap_generated_secrets.php` derives the second from `APP_PUBLIC_URL` so there is one
less thing to fill in — but **only when `MERCURE_PUBLIC_URL` is unset or empty**, and the stock
`compose.yaml` sets it unconditionally to `https://localhost/.well-known/mercure`. On a proxied
install, set it explicitly. `truenas.compose.yaml` leaves its own value blank precisely so the
derivation can happen.
less thing to fill in. It does so when `MERCURE_PUBLIC_URL` is unset, empty, or still
`https://localhost/.well-known/mercure`, the value the stock `compose.yaml` sets on every install.
Any other value is yours and is left alone. So a proxied install needs `APP_PUBLIC_URL` and nothing
more — from the environment as above, or from the setup screen.
The scheme matters as well as the host: Symfony derives the subscriber cookie's `secure` flag from
this URL, so an `https` value on a plain-HTTP install mints a cookie the browser will not send back.
@@ -221,10 +220,10 @@ failure and retries with backoff.
## Things that bite
**`MERCURE_PUBLIC_URL` is not derived on the stock compose file.** The derivation from
`APP_PUBLIC_URL` only happens when the variable is unset or empty, and `compose.yaml` sets it to
`https://localhost/.well-known/mercure`. This is the single most likely reason live updates work in
development and not in production.
**`MERCURE_PUBLIC_URL` is only derived once a public address exists.** With no `APP_PUBLIC_URL` in
the environment and none stored by the setup screen, the stock compose file's
`https://localhost/.well-known/mercure` stays, and live updates work at `https://localhost` and
nowhere else. Admin → Address shows what is stored.
**Push registration failing is not an error state.** Registration is retried hourly by
`app:calendar:push` rather than being tied to the click that connected a calendar, so an install
+19
View File
@@ -10,6 +10,25 @@
# the same reason (BackgroundProcesses); browsers reconnect.
grace_period 5s
# The certificate a browser gets when it asked for a name this server
# holds none for — `default_sni` for a handshake that names nothing,
# which is every one to an IP address, `fallback_sni` for a name
# nobody listed, such as the machine's own hostname on a LAN.
#
# Without them Caddy has nothing to offer and ends the handshake. The
# browser reports that as a protocol error, not a certificate error,
# and a protocol error has no "proceed anyway": an install on a
# headless machine could not be opened from any other machine, and the
# README's "open https://localhost" was the only address that worked.
# With them the answer is the self-signed `localhost` certificate and
# the warning every browser lets a person click through.
#
# Inert wherever there is no such certificate to hand out: an install
# serving a real domain never issues one, and SERVER_NAME ":80" does
# no TLS at all. compose.yaml's default SERVER_NAME is the other half.
default_sni localhost
fallback_sni localhost
{$CADDY_GLOBAL_OPTIONS}
frankenphp {
{$FRANKENPHP_CONFIG}
@@ -88,6 +88,32 @@ final class GeneratedSecretsBootstrapTest extends TestCase
self::assertSame('(unset)', $this->mercurePublicUrlSeenBy([]));
}
/**
* The stock compose file sets the hub address to the `localhost` one on
* every install, so it says nothing about what the operator wanted. An
* install set up from another machine on the LAN kept it, and every browser
* was told to subscribe on its own loopback.
*/
public function testTheStockComposePlaceholderGivesWayToTheAddressChosenDuringSetup(): void
{
file_put_contents($this->secretsFile, "APP_PUBLIC_URL=https://192.168.2.2\n");
self::assertSame(
'https://192.168.2.2/.well-known/mercure',
$this->mercurePublicUrlSeenBy(['MERCURE_PUBLIC_URL' => 'https://localhost/.well-known/mercure']),
);
}
public function testThePlaceholderStaysWhereNoAddressWasEverStored(): void
{
// An install older than the setup question has no address on file and
// is reached at https://localhost, where this value is the right one.
self::assertSame(
'https://localhost/.well-known/mercure',
$this->mercurePublicUrlSeenBy(['MERCURE_PUBLIC_URL' => 'https://localhost/.well-known/mercure']),
);
}
/**
* The subscriber cookie's prefix follows the public address, because a
* browser only keeps a `__Secure-` cookie that arrived over HTTPS. An