SUB/WAVE expects the player, API, tune-in files, and live mounts to share one
public origin, such as https://radio.example.com. This page gives complete
recipes for the split-service docker-compose.byo.yml
deployment and the simpler Cloudflare Tunnel path through the bundled edge.
Set these in the root .env before starting the stack:
SITE_URL=https://radio.example.com
# Use this when nginx runs directly on the same host. It keeps the three BYO
# ports off the LAN while leaving them reachable on loopback.
BIND_ADDRESS=127.0.0.1A proxy in another container cannot reach the host's loopback address. Attach it to SUB/WAVE's Docker network and use service names, or bind the ports to an address that container can reach and protect them with the host firewall.
Every split-service proxy must apply these rules in this order:
| Public path | Upstream | Path sent upstream | Required handling |
|---|---|---|---|
/api/listener-auth |
none | none | Return 404 or otherwise deny it before the general API rule. Icecast calls this password callback over the private Compose network; it must not be public. |
/stream* |
Icecast on :7702 |
unchanged | Disable response buffering, caching, and compression. Match the prefix so optional Opus, FLAC, and AAC mounts work without another proxy edit. |
/listen.pls, /listen.m3u |
Controller on :7701 |
unchanged | These tune-in files are controller routes, not Icecast routes. |
/api/* |
Controller on :7701 |
strip /api |
/api/health must reach the controller as /health. Keep streaming responses such as diagnosis events unbuffered. |
| everything else | Web on :7700 |
unchanged | Player, admin, onboarding, manual, and static assets. |
The examples use the default ports and a proxy on the same host. Replace
127.0.0.1 with an address or Docker service name reachable from your proxy
when it runs elsewhere.
Put these directives inside the existing server block for
radio.example.com; keep your current TLS certificate and redirect settings.
The trailing slash on the controller proxy_pass is what removes /api/.
# Forward the public request identity to every upstream.
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Overwrite, rather than append, so a client cannot seed a forged address.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# Icecast's private URL-auth callback. This must win over location /api/.
location = /api/listener-auth {
return 404;
}
# All present and future stream mounts keep their original path.
location ^~ /stream {
proxy_pass http://127.0.0.1:7702;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
gzip off;
}
# Tune-in files are generated by the controller and keep their root path.
location = /listen.pls {
proxy_pass http://127.0.0.1:7701;
}
location = /listen.m3u {
proxy_pass http://127.0.0.1:7701;
}
# The controller mounts routes at /, so the trailing slash strips /api/.
location /api/ {
proxy_pass http://127.0.0.1:7701/;
proxy_buffering off;
proxy_read_timeout 1h;
}
location / {
proxy_pass http://127.0.0.1:7700;
}If this nginx server is itself behind another trusted edge, configure nginx's
real-IP module with only that edge's addresses first. $remote_addr will then
hold the resolved client address and the rule above will still replace any
client-supplied forwarding chain.
Reload only after nginx accepts the configuration:
sudo nginx -t && sudo systemctl reload nginxCreate one Proxy Host for radio.example.com:
- Scheme:
http - Forward Hostname / IP: the SUB/WAVE host
- Forward Port:
7700 - Websockets Support: on
- SSL: request or select the certificate you normally use
Then open its Advanced tab and paste the block below. Replace
SUBWAVE_HOST with an address reachable from the NPM container; it is usually
the Docker host's LAN address, not 127.0.0.1.
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Overwrite, rather than append, so a client cannot seed a forged address.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
location = /api/listener-auth {
return 404;
}
location ^~ /stream {
proxy_pass http://SUBWAVE_HOST:7702;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
gzip off;
}
location = /listen.pls {
proxy_pass http://SUBWAVE_HOST:7701;
}
location = /listen.m3u {
proxy_pass http://SUBWAVE_HOST:7701;
}
location /api/ {
proxy_pass http://SUBWAVE_HOST:7701/;
proxy_buffering off;
proxy_read_timeout 1h;
}NPM's generated / location continues to send the web UI to the Proxy Host's
port 7700. The Advanced locations override only the API, tune-in files, and
stream mounts.
This overlay attaches the three public services to the external Docker network
used by Traefik and declares one router per rule. Change proxy, websecure,
and letsencrypt if your Traefik installation uses different names. Keep
BIND_ADDRESS=127.0.0.1 in SUB/WAVE's root .env: Traefik reaches the services
over Docker's proxy network, while the inherited BYO port mappings remain
loopback-only.
Save it as docker-compose.traefik.yml beside the SUB/WAVE compose file:
services:
broadcast:
networks:
- default
- proxy
labels:
traefik.enable: "true"
traefik.docker.network: proxy
traefik.http.routers.subwave-stream.rule: "Host(`radio.example.com`) && PathPrefix(`/stream`)"
traefik.http.routers.subwave-stream.entrypoints: websecure
traefik.http.routers.subwave-stream.tls: "true"
traefik.http.routers.subwave-stream.tls.certresolver: letsencrypt
traefik.http.routers.subwave-stream.priority: "20"
traefik.http.routers.subwave-stream.service: subwave-stream
traefik.http.services.subwave-stream.loadbalancer.server.port: "7702"
controller:
networks:
- default
- proxy
labels:
traefik.enable: "true"
traefik.docker.network: proxy
traefik.http.middlewares.subwave-strip-api.stripprefix.prefixes: /api
traefik.http.routers.subwave-api.rule: "Host(`radio.example.com`) && PathPrefix(`/api/`)"
traefik.http.routers.subwave-api.entrypoints: websecure
traefik.http.routers.subwave-api.tls: "true"
traefik.http.routers.subwave-api.tls.certresolver: letsencrypt
traefik.http.routers.subwave-api.priority: "20"
traefik.http.routers.subwave-api.middlewares: subwave-strip-api
traefik.http.routers.subwave-api.service: subwave-controller
traefik.http.routers.subwave-listen.rule: "Host(`radio.example.com`) && (Path(`/listen.pls`) || Path(`/listen.m3u`))"
traefik.http.routers.subwave-listen.entrypoints: websecure
traefik.http.routers.subwave-listen.tls: "true"
traefik.http.routers.subwave-listen.tls.certresolver: letsencrypt
traefik.http.routers.subwave-listen.priority: "20"
traefik.http.routers.subwave-listen.service: subwave-controller
traefik.http.services.subwave-controller.loadbalancer.server.port: "7701"
web:
networks:
- default
- proxy
labels:
traefik.enable: "true"
traefik.docker.network: proxy
# Route the private callback to Next.js, where it is a guaranteed 404.
# Priority 100 makes this rule win over the controller's /api prefix.
traefik.http.routers.subwave-auth-block.rule: "Host(`radio.example.com`) && Path(`/api/listener-auth`)"
traefik.http.routers.subwave-auth-block.entrypoints: websecure
traefik.http.routers.subwave-auth-block.tls: "true"
traefik.http.routers.subwave-auth-block.tls.certresolver: letsencrypt
traefik.http.routers.subwave-auth-block.priority: "100"
traefik.http.routers.subwave-auth-block.service: subwave-web
traefik.http.routers.subwave-web.rule: "Host(`radio.example.com`)"
traefik.http.routers.subwave-web.entrypoints: websecure
traefik.http.routers.subwave-web.tls: "true"
traefik.http.routers.subwave-web.tls.certresolver: letsencrypt
traefik.http.routers.subwave-web.priority: "1"
traefik.http.routers.subwave-web.service: subwave-web
traefik.http.services.subwave-web.loadbalancer.server.port: "7700"
networks:
proxy:
external: trueTraefik forwards response bodies as they arrive unless a buffering middleware
is added, so the stream router needs no buffering option. Do not attach
Traefik's Buffering or Compress middleware to subwave-stream; if either is
configured on the websecure entrypoint, it applies to every router on that
entrypoint and cannot be exempted per router. Remove the global middleware or
give the stream router a dedicated entrypoint without it. Start SUB/WAVE with
both files:
docker compose \
-f docker-compose.byo.yml \
-f docker-compose.traefik.yml \
up -dKeep the bundled edge for this topology. The Caddy container already owns the route contract, so the tunnel needs one public hostname and one upstream—not four path rules. Before trusting private proxy addresses, close Caddy's direct public route so every request must pass through the tunnel:
- If
cloudflaredruns on the host, change Caddy's port mapping indocker-compose.ymlto127.0.0.1:${CADDY_PORT:-7700}:80(or firewall port 7700 to the tunnel host) and usehttp://127.0.0.1:7700below. - If
cloudflaredruns in Docker, remove Caddy's hostportsmapping, attachcloudflaredto SUB/WAVE's private Docker network, and usehttp://caddy:80. The tunnel must then be the only public ingress.
For a locally managed tunnel running on the host, use:
tunnel: YOUR-TUNNEL-UUID
credentials-file: /etc/cloudflared/YOUR-TUNNEL-UUID.json
ingress:
- hostname: radio.example.com
service: http://127.0.0.1:7700
- service: http_status:404In the Cloudflare dashboard, the equivalent is a single published application whose service URL is the bundled edge.
Validate a locally managed configuration before running the tunnel:
cloudflared tunnel --config /etc/cloudflared/config.yml ingress validateCreate a Cache Rule that bypasses cache for paths beginning with /stream.
Cloudflare Access should not guard the stream mount unless every listening
client can complete its login flow; use SUB/WAVE's own stream password when
ordinary players and hardware radios need access.
Cloudflare Tunnel reaches Caddy from a private address rather than a Cloudflare edge address. Once the direct public route is closed, preserve real listener IPs with the trusted-proxy setup in deployment.md.
A common homelab shape: the player and the stream reachable from the internet, the operator console reachable only from home. SUB/WAVE has no setting for this — the split is made at your proxy, and this section is the recipe.
There are two operator surfaces, and only one of them separates cleanly by path.
| Surface | Paths | Can you restrict it by path? |
|---|---|---|
| Operator UI | /admin*, /onboarding |
Yes. Distinct prefixes, served by the web container. |
| Operator API | admin routes under /api/* |
No. The controller mounts public and admin routes on one flat namespace — /api/now-playing and /api/settings are siblings. There is no /api/admin/ prefix to deny. |
So the practical split is: deny the console's pages from the internet, and
let the admin API stay guarded by HTTP Basic auth, which is what guards it
today on every default install (ADMIN_USER / ADMIN_PASS, mandatory in
production). That gets you the requested outcome — nobody outside your LAN sees
a login box, let alone a console — without an allowlist that breaks the next
time a listener-facing endpoint is added.
If you want the admin API off the public interface as well, see Two front doors below.
Leave SITE_URL pointing at the public origin either way. It's the
canonical address the tune-in files, share cards and the Connect panel hand
out; the LAN address is how you reach the box, not how listeners reach the
station.
Add this to the recipe you already applied, above the catch-all web rule.
nginx — order matters, location prefixes are matched before the /
fallback:
# Operator console: LAN only. Everything else on this server block stays public.
location ~ ^/(admin|onboarding) {
allow 192.168.1.0/24; # your LAN
allow 100.64.0.0/10; # Tailscale, if you use it
deny all;
proxy_pass http://127.0.0.1:7700;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}Caddy — including the bundled docker/Caddyfile, where this goes above the
final handle:
@console path /admin /admin/* /onboarding /onboarding/*
handle @console {
@notlan not remote_ip 192.168.1.0/24 100.64.0.0/10
respond @notlan 404
reverse_proxy web:7700
}404 rather than 403 — a 403 confirms there is a console there to find.
Traefik — attach an ipAllowList middleware to a higher-priority router
matching the same paths:
labels:
- "traefik.http.middlewares.subwave-lan.ipallowlist.sourcerange=192.168.1.0/24,100.64.0.0/10"
- "traefik.http.routers.subwave-console.rule=Host(`radio.example.com`) && (PathPrefix(`/admin`) || PathPrefix(`/onboarding`))"
- "traefik.http.routers.subwave-console.priority=100"
- "traefik.http.routers.subwave-console.middlewares=subwave-lan@docker"
- "traefik.http.routers.subwave-console.service=subwave-web"Behind Cloudflare or a tunnel, the connecting peer is the edge, not the listener — your allowlist must read the forwarded address, which means the trusted-proxy setup in deployment.md has to be correct first. Test it before you rely on it: a misconfigured trusted-proxy list turns an IP allowlist into either a lockout or a no-op.
Do not add /api to the allowlist. The player calls /api/now-playing,
/api/state, /api/session, /api/request, /api/like and /api/cover/:id
from every listener's browser; denying /api from the internet takes the
public player down with it.
For a genuinely separate admin surface — the console and its API off the
public interface — run two front ends over the same containers. The web bundle
calls /api and /stream.mp3 relative to its own origin, so both work
with the stock image and no rebuild.
Use docker-compose.byo.yml with
BIND_ADDRESS=127.0.0.1, then:
- Public front end (
radio.example.com, port 443 on the WAN) — the route contract above, minus the console:/admin*and/onboardingreturn 404. - LAN front end (
subwave.lan, or port 8443 bound to your LAN interface) — the full route contract, nothing denied.
Then decide what the public front end does with /api/*. Two honest options:
- Pass it through (simple, recommended). The admin API stays reachable from the internet and stays protected by Basic auth. This is the same posture as a default install; the win is that the console pages are gone.
- Allowlist the listener endpoints (strict, brittle). Route only
/api/now-playing,/api/state,/api/session,/api/health,/api/schedule,/api/themes,/api/beacon,/api/cover/*,/api/request,/api/request/*,/api/likeand/api/station-auth, and 404 the rest. It works, and it will silently break a listener feature the first time a release adds an endpoint to that list. PinSUBWAVE_VERSIONand re-read this list at each upgrade if you take this path.
/api/onboarding/status is deliberately absent from that list. The player
calls it to redirect a fresh install to the wizard and treats any non-200 as
"nothing to do", so denying it publicly costs nothing and stops the public
origin advertising an unconfigured station.
/api/listener-auth stays denied on both front ends. Icecast calls the
controller over the private Compose network; it never needs a route.
The AIO image can't do this internally — it bundles Caddy, the controller, the web UI and Icecast behind one host port by design. Put your own proxy in front of that single port and apply the console rule there, exactly as above with the AIO's port as the upstream. That works on Unraid, where the outer proxy is usually SWAG or Nginx Proxy Manager already fronting everything else.
Run these after applying any recipe:
BASE=https://radio.example.com
# Controller reached with /api stripped.
curl -fsS "$BASE/api/health"
# Tune-in files come from the controller at the public root.
curl -fsS "$BASE/listen.pls"
curl -fsS "$BASE/listen.m3u"
# The private Icecast callback must not reach the controller: expect 404/403.
AUTH_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
-X POST "$BASE/api/listener-auth")
case "$AUTH_STATUS" in
403|404) ;;
*) echo "listener-auth is public (HTTP $AUTH_STATUS)" >&2; exit 1 ;;
esac
# Check the stream headers, then confirm a bounded GET receives audio bytes.
curl -fsSI "$BASE/stream.mp3" | grep -i '^content-type:'
STREAM_SAMPLE=$(mktemp)
trap 'rm -f "$STREAM_SAMPLE"' EXIT
curl -fsS --max-time 10 "$BASE/stream.mp3" -o "$STREAM_SAMPLE" || \
test -s "$STREAM_SAMPLE"
test -s "$STREAM_SAMPLE"Also open /admin/connect?tab=integrations; every enabled mount should show a
copyable URL on the same public hostname.
When SUB/WAVE's stream-password setting is on, /listen.pls and /listen.m3u
always return 403; they do not accept credentials. Verify the stream itself
with curl -u listener:PASSWORD "$BASE/stream.mp3" (any username works) or
append ?auth=PASSWORD. See
Private station mode.