Reverse proxy and TLS
Put the panel and the Core API behind a proxy that terminates TLS. Any proxy works. Three things matter, and they are the same everywhere.
Nothing else goes behind it. Minecraft traffic and the Beam relay are raw TCP on their own ports, not HTTP, so a proxy has nothing to add and several ways to get in the way. The relay gets its own certificate - see Certificates.
1. PANEL_API_URL is the browser's view
It must be the public HTTPS URL of the Core API including /api, for example
https://api.example.com/api. The browser makes the calls, so an internal Docker
hostname there produces a panel that loads and then fails everything.
2. CORS is origin-exact
Core compares the browser's origin literally. https://panel.example.com and
https://panel.example.com/ are not the same string, and neither are http and
https. If requests fail with a CORS error, compare the two values character by
character before you look anywhere else.
3. Do not buffer Server-Sent Events
Console output and live status are a streaming response. In Nginx:
proxy_buffering off;
proxy_read_timeout 24h;Without this the console appears to hang and the stats stop updating, while the server itself is perfectly healthy.
4. Custom tabs need a wildcard, and a wildcard needs DNS-01
Only if you turn on Custom tabs -> Tab proxy. Skip this section otherwise.
A proxied tab is served at the ROOT of its own hostname under
TAB_PROXY_HOST_SUFFIX, one host per tab:
TAB_PROXY_HOST_SUFFIX=share.example.comIt is a host and not a path prefix because the apps people put in tabs - BlueMap,
Dynmap - fetch /js/app.js from the origin ROOT. Under a prefix those requests
miss it entirely, and no amount of <base href> rewriting fixes a path-absolute
URL. A separate hostname is also a separate ORIGIN, which is what keeps a
tenant's scripts away from the panel's session token.
Put the suffix on the same registrable domain as the panel. The tab ticket
cookie is set on the tab host and read back from it, and browsers only allow that
same-site. share.example.com beside panel.example.com is fine;
share.example.net is not. Core logs a warning at startup naming both values if
they do not match.
Two DNS records
share.example.com A <proxy IP>
*.share.example.com A <proxy IP>Create both explicitly. A catch-all *.example.com in the zone would answer them
and hide a missing record, which fails later and somewhere else.
The certificate is the part that catches people
*.share.example.com is a SECOND-level wildcard. Two consequences:
Behind Cloudflare, the records must be DNS-only (grey cloud). Free Universal
SSL covers example.com and *.example.com - one level. It does not cover
*.share.example.com, so an orange-clouded tab fails TLS at Cloudflare's edge
before it ever reaches you. Advanced Certificate Manager covers it if you would
rather keep the proxy; grey is free and the traffic terminates at your proxy
anyway.
A wildcard can only be issued over the DNS-01 challenge. HTTP-01 cannot do it, whatever the resolver is called. Check the resolver's static config, not its name.
Traefik, static config:
certificatesResolvers:
letsencrypt:
acme:
email: you@example.com
storage: /letsencrypt/acme.json
dnsChallenge:
provider: cloudflare # or your DNS provider...and the router has to ASK for the wildcard. A Host rule alone gets a
certificate per hostname, so every new tab triggers a fresh issuance and you meet
Let's Encrypt's rate limit quickly:
labels:
- traefik.http.routers.tabs.rule=HostRegexp(`{sub:[a-z0-9-]+}.share.example.com`)
- traefik.http.routers.tabs.tls=true
- traefik.http.routers.tabs.tls.certresolver=letsencrypt
- traefik.http.routers.tabs.tls.domains[0].main=share.example.com
- traefik.http.routers.tabs.tls.domains[0].sans=*.share.example.com
- traefik.http.services.tabs.loadbalancer.server.port=25500Tabs are served by CORE, on its normal port. They need no port of their own.
Traefik v3 spells the rule differently - HostRegexp there takes a plain
regular expression, ^[a-z0-9-]+\.share\.example\.com$, while v2 uses the
{name:pattern} form above. Keep share.example.com itself pointed
at the PANEL - it serves the share wrapper, and the tab content lives one label
down on purpose, so a framed container cannot reach into the page that frames it.
Check it before you need it
# both names must be in ONE certificate
openssl s_client -connect share.example.com:443 -servername anylabel.share.example.com </dev/null 2>/dev/null |
openssl x509 -noout -text |
grep -A1 'Subject Alternative Name'If a tab shows a certificate warning, this is the first thing to look at, and usually the only thing.
Example: Nginx Proxy Manager
Two proxy hosts:
panel.example.comtopanel:25510api.example.comtocore:25500
Enable Websockets Support on the API host, which is what the UI calls the setting that stops it from buffering streamed responses.
Example: Traefik
labels:
- traefik.enable=true
- traefik.http.routers.panel.rule=Host(`panel.example.com`)
- traefik.http.services.panel.loadbalancer.server.port=25510The same shape for Core on port 25500.
Security headers
Set them in one place. If the proxy adds HSTS, X-Frame-Options,
X-Content-Type-Options and Referrer-Policy, do not add them in the
application as well. Duplicated headers conflict, and browsers do not resolve the
conflict in your favour.