VOL. I  ·  NO. 
SUB/WAVE
ON AIR
No skips · No shuffle · Just radio

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.

PREFER THE CLI?

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.

01

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

Three 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.com
PREFER A CLONE?

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

02

Boot the stack

docker compose up -d

What 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 (no scripts/setup.sh step 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)
PIN A VERSION

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.

ALREADY RUNNING TRAEFIK OR NGINX?

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 → web

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

03

Finish setup in the browser

open http://localhost:7700/onboarding

Or 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 into process.env on 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-heavy sidecar: tick the “Enable Chatterbox + PocketTTS” box in the wizard, then start it with docker compose --profile tts-heavy up -d (or set COMPOSE_PROFILES=tts-heavy in .env so future up -d calls 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.
PREFER THE TERMINAL?

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

04

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

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

DAY-TO-DAY OPERATOR CONSOLE

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.