Publishing the dashboards on a domain
Both dashboards ship bound to 127.0.0.1 and speak plain HTTP. That is a
deliberate default, not an oversight — but it means the only way in is an
SSH tunnel, so you cannot hand a dashboard to a colleague without also
handing them root on the box.
This is the walkthrough for putting one on a real hostname with TLS and a password. It is written from an actual deployment, including the parts that went wrong.
Do the broadcaster first. The panel dashboard can create users and grant channels; the broadcaster control API can start and stop channels. Both deserve care, but the panel is the higher-value target — get the pattern right on the smaller surface.
1. DNS first, before you install anything
Point an A record at the box and verify it resolves before Caddy ever runs:
$ getent hosts studio.example.com
203.0.113.10 studio.example.com
$ curl -s -4 ifconfig.me # must be the same address
203.0.113.10
Let's Encrypt caps failed validations at 5 per hour per hostname. Starting Caddy against DNS that has not propagated burns that budget for the name you actually want.
Pick a hostname you own outright. A domain carrying someone else's brand — a well-known product name on a different TLD, or a near-miss spelling — gets blocklisted by reputation feeds, is exposed to UDRP transfer, and CAs revoke certificates on trademark complaints. Anything you build on such a name is on borrowed time.
2. Firewall — and the part that bites P2P nodes
If you run ufw, add the SSH rule before enabling it, and read the
UDP note:
$ ufw allow 22/tcp # FIRST, always
$ ufw allow 80,443/tcp # Caddy
$ ufw allow 32768:60999/udp # ⚠ the swarm — see below
$ ufw --force enable
⚠ The UDP range is load-bearing. A broadcaster holds roughly two UDP
sockets per channel — at 69 channels that is ~140 — bound to 0.0.0.0
on random ephemeral ports that change on every restart. No static
per-port rule can name them. ufw's default deny incoming would drop
unsolicited inbound UDP to all of them.
Hole-punched flows mostly survive via conntrack, so this is easy to miss. But a VPS has a public IP and no NAT: peers can address it directly, and that first inbound packet is exactly what gets dropped. The result is degraded seeding with nothing logged anywhere.
Verify after enabling, by watching the counters move in both directions:
$ grep "^Udp:" /proc/net/snmp | tail -1 # InDatagrams … OutDatagrams … RcvbufErrors
Inbound should climb roughly in step with outbound, and RcvbufErrors
should stay at 0. If inbound flatlines while outbound climbs, your UDP
rule is wrong.
3. Install Caddy on the host, not in a container
The services run network_mode: host and bind loopback, so a
host-installed Caddy reaches them directly. A containerised Caddy would
need host networking anyway.
$ apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
$ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
$ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
> /etc/apt/sources.list.d/caddy-stable.list
$ apt-get update && apt-get install -y caddy
$ systemctl stop caddy # write the config before it serves anything
4. Generate the proxy credential without it touching your shell history
$ PW=$(openssl rand -base64 18)
$ caddy hash-password --plaintext "$PW"
$2a$14$…
$ printf 'dashboard operator %s\n' "$PW" >> ~/dashboard-password.txt
$ chmod 600 ~/dashboard-password.txt
$ unset PW
Put the hash in the Caddyfile and keep the plaintext only in that
0600 file. The bcrypt hash is a verifier, not a credential — but the
plaintext is still worth protecting.
5. The Caddyfile — and the one rule everybody gets wrong
studio.example.com {
@ui not path /api/*
basic_auth @ui {
operator $2a$14$YOUR_HASH_HERE
}
reverse_proxy 127.0.0.1:3310
}
(basic_auth is the Caddy ≥ 2.8 spelling of the directive; older packaged
builds call it basicauth — same syntax otherwise.)
⚠ basic_auth must not cover /api/*. HTTP allows exactly one
Authorization header, and both layers want it. The dashboard
authenticates to its own API with Authorization: Bearer <token>. The
moment its JavaScript sets that, it replaces the browser's automatic
Authorization: Basic …. Caddy sees a non-Basic value, rejects it, and
replies 401 with WWW-Authenticate: Basic — the header that tells a
browser to open a login prompt. This happens on every API call.
The symptom is unmistakable and baffling if you don't know the cause: the
password popup keeps reappearing on every click. The @ui matcher is the
fix.
$ caddy validate --config /etc/caddy/Caddyfile
$ systemctl start caddy
Give it a few seconds. Issuance is automatic — in production it completed
via the tls-alpn-01 challenge on port 443 with no extra configuration —
but a curl fired immediately after start returns 000 because it
raced that ACME exchange. That is not a failure; retry a few seconds
later.
6. Verify, including the case that actually broke
$ curl -s -o /dev/null -w '%{http_code}\n' https://studio.example.com/ # 401
$ curl -s -o /dev/null -w '%{http_code}\n' -u operator:PASS https://studio.example.com/ # 200
Then run the test that catches the header collision — drive the exact request the browser sends, a Bearer token through the proxy:
$ TOK=$(curl -s -X POST -H 'content-type: application/json' \
-d '{"username":"admin","password":"…"}' https://studio.example.com/api/login \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
$ curl -s -o /dev/null -D- -H "Authorization: Bearer $TOK" \
https://studio.example.com/api/channels | grep -iE '^HTTP/|www-authenticate'
HTTP/2 200
A www-authenticate header on that response means the collision is
still there. Testing the layers separately — Basic without Bearer,
Bearer without Basic — passes even when the combination is broken,
because the collision only exists in the combined request.
7. What you have, stated honestly
- The UI has two gates: the proxy password, then the dashboard's own login.
- The API has one.
/api/*, including/api/login, bypasses basic auth and is protected solely by the app's rate-limited Bearer auth.
Scoping basic auth to the UI keeps casual scanners from getting a working
dashboard. It does not harden the API, and no header-based mechanism
can, because they all collide the same way. For a genuine second layer on
the API use an IP allowlist (remote_ip) or mTLS — neither touches
the Authorization header. See deploy/Caddyfile.example for an
allowlist block.
No public IP or open ports? Cloudflare Tunnel
Everything above assumes a box with a public IP and ports 80/443 open.
Behind NAT/CGNAT or a firewall you cannot open, the alternative is a
Cloudflare Tunnel: cloudflared runs on the host, dials out to
Cloudflare's edge, and Cloudflare terminates TLS on your hostname and
proxies requests down the tunnel to the loopback-bound dashboard. There is
no inbound port and no published origin IP — DNS, certificates, and
renewal all become Cloudflare's problem.
The worked example targets the reseller panel — the one dashboard built
for third-party users — in deploy/cloudflared.compose.example.yml, with
the security notes (the TRUST_PROXY_HEADER=cf-connecting-ip lockout
keying, why CONTROL_HOST stays loopback, where IP allowlists move) in
Reseller panel → Exposing the dashboard safely.
One difference from basic auth worth knowing: Cloudflare Access fronts
a hostname using its own cookie, so it does not collide with the
dashboards' Authorization: Bearer logins — it is the rare second gate
that can also cover /api/*.
Certificate renewal
Caddy renews automatically and systemd restarts it on boot — confirm
both:
$ systemctl is-enabled caddy
$ echo | openssl s_client -connect studio.example.com:443 2>/dev/null \
| openssl x509 -noout -dates
Renewal uses the same port 80/443 path as issuance, so if you later tighten the firewall, keep them open or renewal fails silently until the cert expires.