TLS configuration
Mycelium2 always serves HTTPS; HTTP on the second listener only
redirects. There are three supported postures, plus one knob for the
built-in certificate's names.
Boot precedence
At startup the server picks its certificate in this order:
1. **Env-supplied pair** — `MYCELIUM2_TLS_CERT` + `MYCELIUM2_TLS_KEY`
(or `--tls-cert` / `--tls-key`) when both files exist. The server
loads them and touches nothing else.
2. **Uploaded pair** — `config/tls/uploaded-cert.pem` +
`uploaded-key.pem` in the data directory, installed through
Admin → System → TLS. Survives restarts. An unreadable uploaded
pair logs a warning and falls back to the generated pair rather
than failing boot.
3. **Generated pair** — `config/tls/cert.pem` + `key.pem`, a
self-signed certificate Mycelium2 creates on first run and keeps
current (see SANs below). The generated pair is maintained even
while an uploaded pair is active, so reverting always has a
working fallback.
The generated pair is **managed** with the sidecar as the source of
truth: `config/tls/sans.txt` records the SAN set it was built for.
Once that sidecar exists and the pair is complete, boot never
regenerates the pair — the env/flag SAN list is only the FIRST-BOOT
initializer. This is what makes the admin UI's **Regenerate
self-signed certificate** action survive restarts (see below): the
recorded set wins over whatever the environment says on a later boot.
A hand-placed `config/tls/cert.pem` + `key.pem` pair that predates
this version — one you dropped in yourself, with no `sans.txt` beside
it — is silently deleted and regenerated on the first boot after
upgrade. If the pair is incomplete (a crash between writes), boot
rebuilds it from the **recorded** set. Removing `sans.txt` is the
escape hatch that hands control back to the env/flag initializer.
Real certificates do not belong in that directory at all: mount them
via `MYCELIUM2_TLS_CERT` / `MYCELIUM2_TLS_KEY` (precedence 1) or
install them through the upload flow (precedence 2), neither of which
the manager ever touches.
Posture 1: built-in self-signed (with your names)
With no configuration the generated certificate carries the SAN
`localhost`. To get your own names in the self-signed cert, set the
SAN list:
MYCELIUM2_TLS_SANS="mycelium.example,192.0.2.10,localhost" mycelium2 # or mycelium2 --tls-sans "mycelium.example,192.0.2.10,localhost"
The list is comma-separated and taken **verbatim** — nothing is
force-added, so include `localhost` if you also browse via the loopback
name. An unset or empty value means exactly `localhost`. The env/flag
list is the **first-boot initializer**: the server records it in
`config/tls/sans.txt`, and from then on the sidecar — not the
environment — owns the generated pair (see Boot precedence above). To
change the names later, either regenerate from the admin UI
(below) or delete `sans.txt` and restart. The uploaded pair is never
regenerated or touched by any of this.
Posture 2: uploaded third-party certificate
Admin → System → TLS is the whole workflow:
- **Upload certificate** — a cert (+ optional issuer chain, leaf
first) and its private key, with an optional label. Everything is
validated before anything is written: both PEMs parse, the key
matches the certificate, and — when a CSR is pending — the uploaded
key must be exactly the key the CSR was generated with. An expired
or soon-to-expire certificate installs with a warning rather than a
rejection. On success the live listener hot-swaps to the new pair
(new connections pick it up immediately; existing connections keep
their negotiated parameters).
- **Download certificate** — the active cert PEM, for distributing to
clients that need to trust it.
- **Revert to generated** — removes the uploaded pair and reloads the
generated one. The generated pair is never deleted by this action.
- **Regenerate self-signed certificate** — mints a fresh self-signed
key and certificate with the given SAN list (comma-separated; empty
means `localhost`; at most 64 names of 253 characters each), even
when the SANs are unchanged — the action is
never a silent no-op. The live listener hot-swaps to the fresh pair
when no uploaded cert is active; under an active upload the fresh
pair becomes the fallback and the uploaded cert keeps serving. The
choice is recorded in `sans.txt` and survives restarts (see Boot
precedence). Clients that trusted the old certificate must re-trust
the new one — its fingerprint changes.
CSR → CA → upload walkthrough
If your CA wants a certificate signing request, generate one on the
same card: **Generate CSR** takes a common name and a SAN list
(comma-separated; empty means `localhost`), downloads the CSR PEM, and
keeps the matching private key server-side (mode 0600) as *pending*.
While a CSR is pending, uploads must carry exactly that key — the card
shows the pending CN/SANs and offers re-download and discard.
Have acme.sh issue via manual DNS (no account or automation needed):
acme.sh --issue --dns dns_yourprovider \ -d mycelium.example \ -d www.mycelium.example \ --csr /path/to/csr.pem
Or sign with an internal CA using openssl (create a v3 ext file
carrying the SANs you requested, then):
openssl ca -batch -config ca.cnf -extensions server \ -in csr.pem -out issued.pem
Upload `issued.pem` (plus your chain, leaf first) through the card
together with the pending key — the upload form always takes both a
certificate and a key, and the key must be exactly the one the CSR was
made from. That key never leaves the server: read it off disk at
`<data_dir>/config/tls/pending-csr-key.pem` (the card prints the same
path) and pick it as the **Private key (PEM)** file. A successful
install clears the pending CSR. Discarding a pending CSR is fine — it
just means the next upload is an independent pair, and you supply both
halves yourself.
Posture 3: reverse proxy terminates TLS
Run your proxy (nginx, Caddy, …) with your real certificate in front of
Mycelium2. Mycelium2 always terminates its own TLS, so the proxy must
forward to the **HTTPS** listener, not the HTTP one — the HTTP listener
is redirect-only (it 303s every request to the HTTPS port), so proxying
to it either loops or bypasses your proxy entirely. Bind Mycelium2 to
loopback and point the proxy at it over TLS:
MYCELIUM2_HTTPS_ADDR=127.0.0.1:8443
# nginx: terminate TLS here, proxy to Mycelium2's HTTPS listener
location / {
proxy_pass https://127.0.0.1:8443;
proxy_ssl_verify on; # proxy_ssl_trusted_certificate has no effect without this
proxy_ssl_trusted_certificate <data_dir>/config/tls/cert.pem; # the self-signed pair, or set proxy_ssl_verify off
}
(See Deployment — *Behind a reverse proxy* — for the
full `proxy_pass https://…` recipe.) Two things to get right:
- The proxy connects to Mycelium2 over TLS, so it must trust (or be told
to skip verifying) the backend certificate — the self-signed pair from
Admin → System → TLS, or the `MYCELIUM2_TLS_CERT`/`MYCELIUM2_TLS_KEY`
pair if you mounted one.
- Pass `MYCELIUM2_WEBAUTHN_ORIGIN` as the public HTTPS origin your
browser actually sees (e.g. `https://mycelium.example`) — passkey
ceremonies are refused when the relying-party origin does not match.
Trusting the self-signed certificate
Downloading the cert from Admin → System → TLS gives you the file;
clients that validate hostnames will still reject it unless they trust
it (or the SANs do not cover the name you browse by — issue #7's
background, which `MYCELIUM2_TLS_SANS` fixes).
Node-based clients (including MCP clients) trust extra CAs via:
export NODE_EXTRA_CA_CERTS=/path/to/mycelium-cert.pem
On Debian/Ubuntu, install it into the system trust store:
sudo cp mycelium-cert.pem /usr/local/share/ca-certificates/mycelium.crt sudo update-ca-certificates
curl and Python `requests` read the system store; Python `httpx` and
most tools also accept an explicit CA path (e.g. `--cacert`,
`verify=`).
Hot reload scope
Uploading or reverting reloads the live TLS config, which applies to
**new connections** — rustls picks up the config per-connection at
accept time. Long-lived keep-alive connections finish on the old
parameters; nothing forces them down.