SETUP · 03
Run the commands yourself.
The no-CLI route: same result, no `subwave` binary on your host. Reach for it if you'd rather not run an installer, you're scripting the deploy, or you just like typing the commands yourself. Four steps get you a public single-host deploy: Caddy on the edge, Cloudflare in front, and Icecast, Controller, and Web kept internal.
If a single binary on your host doesn't bother you, the standalone CLI folds these four steps into curl … | sh (which chains init and start behind two Enter prompts) followed by subwave setup. See Quick Start. It pulls the same compose images and writes to the same state/ layout, so you can switch between the two whenever you like.
Grab the two files
No clone needed. SUB/WAVE installs from a single docker-compose.yml + a 3-var .env:
mkdir subwave && cd subwave
curl -O https://raw.githubusercontent.com/perminder-klair/subwave/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/perminder-klair/subwave/main/.env.example
mv .env.example .env
$EDITOR .envThree keys boot the stack. The first-run wizard at /onboarding collects the rest once the containers come up.
# .env (repo root) — three keys, that's the whole boot config.
# Admin gate for /admin + the first-run wizard. REQUIRED in prod.
ADMIN_USER=admin
ADMIN_PASS=replace-me-with-a-strong-string # openssl rand -hex 16
# Public origin — used for OG tags, sitemap, share cards.
SITE_URL=https://radio.example.comClone the repo and run ./scripts/setup.sh. It scaffolds the same .env and sets the state-dir permissions. Or run npm run setup for a terminal wizard that does the same thing the browser flow does, no browser needed.
Boot the stack
docker compose up -dWhat just started:
- broadcast — icecast2 and liquidsoap together in one container. On first boot it generates three random Icecast passwords and saves them to
state/icecast-secrets.env(noscripts/setup.shstep needed). The entrypoint sources them before it execs liquidsoap. Internal-only. - controller — the DJ brain. This is the one that talks to Navidrome and your LLM.
- web — Next.js UI, internal-only
- caddy — the only thing bound to a host port (
:7700)
By default, docker-compose.yml pulls ghcr.io/perminder-klair/subwave-*:latest. To pin a release, set SUBWAVE_VERSION=v1.2.3 in your root .env. To build from a local clone instead, add --build to the up command.
Swap the compose file for docker-compose.byo.yml: same stack minus the bundled Caddy, with web / controller / broadcast bound to :7700 / :7701 / :7702.
You have to front this with a reverse proxy. The web UI calls /api/*, /stream.mp3, and /stream.opus same-origin, and those paths are baked into the image at build time. Without a proxy routing them to the controller and Icecast, the page loads but the player is dead: no metadata, no audio. Here's the route table to replicate (it mirrors docker/Caddyfile):
/stream.mp3 → host:7702 # disable proxy buffering for live audio
/stream.opus → host:7702 # ditto — Ogg-Opus mount served from same Icecast
/api/* → host:7701/* # strip the /api prefix
/* → host:7700 # everything else → webIf you need separate hostnames per surface, rebuild the web image with NEXT_PUBLIC_API_URL and NEXT_PUBLIC_STREAM_URL set; those are baked at build time, not runtime.
Finish setup in the browser
open http://localhost:7700/onboardingOr https://your-host/onboarding if you're not on localhost.
Sign in with the ADMIN_USER / ADMIN_PASS you set in .env. The wizard collects each of these, tests them against the live service, and saves them:
- Navidrome — URL + user + pass. Saved to
state/setup-config.json. - LLM provider + model — Ollama (homelab default, no key), Anthropic, OpenAI, Google, DeepSeek, OpenRouter, Vercel AI Gateway, or any self-hosted OpenAI-compatible server. Cloud API keys go to
state/secrets.env(mode 0600, sourced intoprocess.envon boot). - TTS engine — Piper (default) and Kokoro both run inside the controller image. Cloud (OpenAI / ElevenLabs) just needs an API key. Chatterbox (voice cloning) and PocketTTS (multilingual) live in the optional
tts-heavysidecar: tick the “Enable Chatterbox + PocketTTS” box in the wizard, then start it withdocker compose --profile tts-heavy up -d(or setCOMPOSE_PROFILES=tts-heavyin.envso futureup -dcalls bring it up automatically). - DJ persona — station name, location for weather, optional system-prompt override.
- Jingles — one-click button to render 5 default station idents via your chosen TTS engine.
npm run setup walks the same flow in the terminal. Same checks, same saved config, same end state. That path needs a git clone and Node 20+.
Verify the broadcast
The repo ships a health probe. It checks the containers, hits /api/health and /api/now-playing, and scans recent logs for errors. Run it after any deploy:
./scripts/health-check.shAuto-detects which compose file is live and which host port Caddy is mapped to. Exits 0 if healthy. Safe to wire into cron or a status page.
npm start opens the operator console: a menu for stack status, a diagnostic sweep, logs, restart, and the terminal player. It's how you'll run the station day to day once it's installed.
WHAT'S NEXT
Keep it running.
The stack is on the air. When a new version lands, head to Updates & Help for the rebuild-only-what-changed workflow and a troubleshooting checklist.