Files
mc-webui/docs/https-setup.md
T
MarekWoandClaude Opus 5 05ec5fa13e feat(https): optional HTTPS front end via Nginx Proxy Manager
Adds an opt-in HTTPS layer without touching anything for existing installs.

The proxy is a service in docker-compose.yml behind the "https" Compose
profile, activated with COMPOSE_PROFILES=https in .env. That keeps the whole
setup inside files git owns while the switch lives in a file it ignores --
important because scripts/update.sh runs `git pull`, so any user edit to a
tracked compose file would break the next update with a merge conflict.
mcupdate needs no change: compose reads COMPOSE_PROFILES from .env itself.

Both services share the project's default network, so the proxy forwards to
http://mc-webui:5000 internally; MC_BIND_ADDRESS lets the plain-HTTP port be
restricted to loopback once HTTPS works, and MC_TRUST_PROXY turns on ProxyFix
so the app sees the real client address and scheme.

Also fixes copying over plain HTTP. navigator.clipboard only exists in a
secure context, and 9 call sites across 5 entry points used it with no
fallback, so copy buttons silently did nothing on a LAN address. They now
share clipboard-utils.js, loaded via _head_i18n.html (the one include every
entry point already has), which falls back to execCommand.

Verified locally against the real stack: self-signed cert uploaded to NPM,
proxy host created, app served over HTTPS with socket.io upgrading to a real
wss:// WebSocket (transport "websocket"), and the clipboard fallback checked
end-to-end by pasting back what it copied over http://.

Two findings from that run are documented in docs/https-setup.md:
NPM 2.15 dropped the default admin@example.com account for a setup wizard,
and its default 443 server now refuses connections without SNI -- which is
every connection made to a bare IP address. docker/npm-default-site.conf is
the opt-in way around that, mounted writable (a :ro mount aborts s6 init and
leaves nginx not listening at all).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:58:03 +02:00

15 KiB
Raw Blame History

HTTPS Setup

By default mc-webui speaks plain HTTP. This guide turns on an optional HTTPS front end using Nginx Proxy Manager (NPM) — a small web UI where you point-and-click your way to a certificate, instead of editing nginx config files.

Everything here is opt-in. If you skip this document, nothing about your installation changes.

Why bother

  • Encrypted traffic. Nobody on your network reads your messages or your device configuration in transit.
  • Copy buttons, and more, start working. Browsers restrict a set of features to a secure context — HTTPS, or http://localhost. Open mc-webui over plain HTTP at http://192.168.1.50:5000 and the modern clipboard API simply does not exist, so copy buttons fall back to an older, less reliable method. The same applies to installing mc-webui as an app on a phone, and to browser notifications.
  • A real hostname. https://mesh.example.com instead of an IP and a port number.
  • Optional password protection. NPM's Access Lists can put HTTP authentication in front of mc-webui, which has no login of its own.

How it fits together

   browser  ──HTTPS(443)──►  nginx-proxy-manager  ──HTTP──►  mc-webui  ──►  MeshCore device
                             (container: mc-webui-proxy)     (container: mc-webui)

Both containers belong to the same Compose project, so they share a private Docker network. The proxy reaches the app as http://mc-webui:5000 over that network — the app does not need to publish a port at all, and the traffic between them never leaves the host.


Step 1 — Turn it on

Edit .env in your mc-webui directory (create it from .env.example if you have none) and add:

COMPOSE_PROFILES=https

Then start it the usual way:

docker compose up -d --build

That is the whole installation. The proxy is defined in the project's docker-compose.yml behind a Compose profile, so it stays completely inert until that line exists — and mcupdate picks it up automatically from then on. Do not edit docker-compose.yml yourself: it is tracked in git, and a local edit will make the next update fail with a merge conflict.

Check that both containers are up:

docker compose ps

You should see mc-webui and mc-webui-proxy.

Port 80 or 443 already in use? If something else on the host owns those ports, docker compose up fails with address already in use. Either stop the other service, or move the proxy's ports with NPM_HTTP_PORT / NPM_HTTPS_PORT in .env. Note that Let's Encrypt's HTTP validation requires the public port 80, so moving it rules that method out — use the DNS method (Option B) instead.

Step 2 — Log in to the proxy admin UI

Open http://<your-server>:81. On first run you get a short setup form where you create your own administrator account — name, email and password. There is no default login to change afterwards.

Keep this panel on the local network and do not forward port 81 through your router: it can route traffic anywhere inside your network, and it is served over plain HTTP.

Older Nginx Proxy Manager releases (before 2.15) instead shipped a built-in admin@example.com / changeme account that you were expected to change on first login. If you pinned an older image with NPM_IMAGE, that is what you will see.

Step 3 — Create the proxy host

Go to Hosts → Proxy Hosts → Add Proxy Host and fill in the Details tab:

Field Value
Domain Names mesh.example.com — your hostname, or the server's IP address
Scheme http
Forward Hostname / IP mc-webui
Forward Port 5000
Cache Assets off
Block Common Exploits on
Websockets Support on — this one is not optional

Websockets Support must be on

mc-webui pushes new messages to open pages over a WebSocket. Without this switch, nginx does not forward the connection upgrade, the page silently drops back to long-polling, and a browser only allows six long-polling connections per address across all tabs. Two or three open tabs then exhaust that budget and the interface crawls — clicks take ten or twenty seconds while the server sits idle. It looks exactly like an overloaded server and it is not one. If you have seen that symptom before, this is the same bug coming back through the proxy.

Save. http://mesh.example.com should now show mc-webui. Certificates come next.

Step 4 — Choose a certificate

Three routes, depending on whether you have a domain name and whether your server is reachable from the internet.

Option A — Let's Encrypt with a public domain (the easy case)

Requires: a domain name pointing at your public IP, and port 80 forwarded from your router to this host.

Edit the proxy host → SSL tab → SSL Certificate: Request a new SSL Certificate → enable Force SSL and HTTP/2 Support → agree to the terms → Save.

That is it. The certificate is trusted by every browser and phone with no warnings, and NPM renews it automatically.

Option B — Let's Encrypt with a DNS challenge (best for LAN-only servers)

This is the option most home installations want. It gives you a fully trusted certificate on a server that is not exposed to the internet at all — no port forwarding, nothing reachable from outside.

The trick: Let's Encrypt proves you own the domain by having you write a token into its DNS, and never connects to your server. Nothing stops that domain's A record from pointing at a private address such as 192.168.1.50.

Requires: a domain you own, hosted at a DNS provider NPM supports (Cloudflare, deSEC, DuckDNS, Hetzner, OVH, and around a hundred others), and an API token from it.

  1. In your DNS, point mesh.example.com at the server's LAN address, e.g. 192.168.1.50.
  2. Edit the proxy host → SSL tab → Request a new SSL Certificate.
  3. Enable Use a DNS Challenge, pick your provider and paste its API credentials.
  4. Enable Force SSL, save, and give it a minute or two for DNS to propagate.

Certificate renewal is automatic and equally invisible. Everyone on the LAN reaches https://mesh.example.com with a green padlock, including phones and the Android app.

Option C — Self-signed, or a private CA (no domain at all)

Use when you only ever reach the server by IP address, e.g. https://192.168.1.50.

First, the part that is worth being clear about: no public certificate authority will ever issue a certificate for a private address like 192.168.1.50 — nobody can prove they own an address that exists identically inside every home network on earth. Let's Encrypt has recently begun issuing certificates for public IP addresses, but those are short-lived and NPM's certificate integration does not support them. So for a LAN address, the choice is a certificate you sign yourself.

NPM cannot generate one, so create it on the host and upload it. The subjectAltName line is what matters — browsers ignore the old Common Name entirely, and a certificate without a matching SAN is rejected outright:

openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
  -keyout mc-webui.key -out mc-webui.crt \
  -subj "/CN=mc-webui" \
  -addext "subjectAltName=IP:192.168.1.50,DNS:mc-webui.local" \
  -addext "basicConstraints=critical,CA:FALSE" \
  -addext "keyUsage=digitalSignature,keyEncipherment" \
  -addext "extendedKeyUsage=serverAuth"

Replace the IP with your server's. List every name and address you will actually type into the browser — each one needs to be in that SAN list.

Then in NPM: SSL Certificates → Add SSL Certificate → Custom, upload mc-webui.key as the key and mc-webui.crt as the certificate (leave the intermediate field empty), and select it on the proxy host's SSL tab.

What you get: real encryption, and the secure-context browser features work. What you do not get: a green padlock. Every browser shows an interstitial warning the first time, which you click through once per browser.

If the warnings bother you, use mkcert instead. It creates a small certificate authority of your own, and installs it into the trust store of the machines you choose — then those machines see no warning at all:

mkcert -install                            # once, per machine that should trust it
mkcert 192.168.1.50 mc-webui.local         # produces a .pem cert and key to upload

Browsing by IP address needs one extra step

Put the IP straight into the proxy host's Domain Names field — NPM accepts it. That is not quite enough on its own, though, and the reason is worth understanding.

When you type a hostname, the browser tells the server which name it is asking for, as part of setting up the encrypted connection (SNI). When you type an IP address it sends nothing — the standard forbids it. Nginx then falls back to a default server for port 443, and since version 2.15 Nginx Proxy Manager ships one whose entire job is to refuse such connections. The browser shows a connection error before it ever gets as far as a certificate warning.

Two ways out.

The clean one: give the server a name. Add a DNS entry on your router (or Pi-hole, or whatever serves DNS on your network) pointing e.g. mesh.lan at the server, and use that. One entry covers every device, including phones — where you cannot edit a hosts file. Everything then works as described above, and this is worth doing anyway.

The direct one: let the proxy answer unnamed requests. This project ships a replacement for that default server at docker/npm-default-site.conf — same file with the refusing block removed, so your proxy host answers instead and presents its certificate. Enable it by creating docker-compose.override.yml next to docker-compose.yml:

services:
  npm:
    volumes:
      - "./docker/npm-default-site.conf:/etc/nginx/conf.d/default.conf"

Then docker compose up -d. That filename is deliberately ignored by git, so it survives updates untouched.

Do not add :ro to that line. The proxy container adjusts ownership of its config files at startup; on a read-only mount that fails, and it aborts the whole startup — the container comes up but nginx never starts listening, so everything refuses connections. It is a confusing failure to debug. Mount it writable, as above.

Step 5 — Tighten it up (optional)

Once HTTPS works, three optional steps make it the only way in.

Force HTTPS. On the proxy host's SSL tab, enable Force SSL so plain HTTP requests redirect. Leave HSTS off until you are sure everything works — HSTS is remembered by browsers for months and is awkward to undo.

Let the app see the real client. In .env:

MC_TRUST_PROXY=true

Without it, mc-webui sees every request as coming from the proxy container and believes the connection is plain HTTP. With it, the log shows real client addresses. Enable it only when the app is reached through the proxy — anyone able to talk to port 5000 directly can forge those headers.

Close the plain HTTP port. Also in .env:

MC_BIND_ADDRESS=127.0.0.1

Port 5000 then answers only on the machine itself, so the network sees nothing but HTTPS. The proxy is unaffected — it reaches the app over the internal Docker network. Set this after HTTPS works, not before, or you will lock yourself out. To undo it, delete the line and run docker compose up -d.

Apply either change with docker compose up -d.

The Android app

The Android wrapper accepts both http:// and https:// addresses, but it rejects certificates it does not trust and shows an SSL error instead of the page. In practice:

  • Options A and B work perfectly — enter https://mesh.example.com and you are done.
  • Option C (self-signed) does not work in the app. Android's WebView does not trust certificates you added to the phone manually, and there is no way around that from inside the app. Use the app over http:// on the LAN, or move to Option B.

Updating and removing

Updating needs nothing special — mcupdate (or docker compose up -d --build) pulls both containers as usual. The proxy's configuration and certificates live in ./data/npm and ./data/letsencrypt and survive rebuilds.

Removing HTTPS: delete or comment out COMPOSE_PROFILES=https in .env, then:

docker compose --profile https down
docker compose up -d

Also remove MC_BIND_ADDRESS and MC_TRUST_PROXY if you set them, or the app stays unreachable from the network. The proxy's data directories are left in place; delete ./data/npm and ./data/letsencrypt by hand if you want them gone.

Troubleshooting

Symptom Cause and fix
The interface crawls with 2–3 tabs open; clicks take 10–20 s Websockets Support is off on the proxy host. Turn it on (Step 3).
502 Bad Gateway The app container is down or still starting — docker compose ps, then docker compose logs mc-webui. Also check Forward Hostname is mc-webui (the container name), not localhost: inside the proxy container, localhost is the proxy itself.
address already in use on startup Something else on the host holds port 80 or 443. Stop it, or set NPM_HTTP_PORT / NPM_HTTPS_PORT.
Let's Encrypt fails with a connection or timeout error HTTP-01 validation could not reach port 80 from the internet. Check the router forward, or switch to a DNS challenge (Option B).
Browser warns about the certificate even after installing a private CA The address you typed is not in the certificate's subjectAltName. Reissue with every name and IP you use.
The Android app shows an SSL error A self-signed certificate — the app cannot trust it. See the Android section above.
HTTPS works by hostname but not by IP address The proxy refuses connections that carry no hostname. See "Browsing by IP address" above.
After mounting npm-default-site.conf, nothing answers — not even port 81 The mount has :ro. Remove it and run docker compose up -d.
Locked out after setting MC_BIND_ADDRESS On the server itself: remove the line from .env and run docker compose up -d.
Proxy container keeps restarting Check docker compose logs npm. A common cause is ./data/npm sitting on a synced folder (Synology Drive, Dropbox) — the sync client corrupts its SQLite database. Move it with NPM_DATA_DIR.

See also