Skip to content
Documentation

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:

shell
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:

shell
TAB_PROXY_HOST_SUFFIX=share.example.com

It 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

shell
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:

shell
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:

shell
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=25500

Tabs 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

shell
# 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.com to panel:25510
  • api.example.com to core: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

shell
labels:
  - traefik.enable=true
  - traefik.http.routers.panel.rule=Host(`panel.example.com`)
  - traefik.http.services.panel.loadbalancer.server.port=25510

The 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.