# SUB/WAVE — production orchestration. # Only Caddy is exposed on the host. Cloudflare terminates TLS in front. # Broadcast (icecast2 + liquidsoap), Controller, and Web are all internal-only # and reachable via Caddy's reverse proxy under one origin (/api, /stream.mp3, /). # # Image-first: every service pulls its baked image from GHCR by default. The # build: blocks let `docker compose build` rebuild locally from a checkout, # but the standard install never needs to clone — operators just need this # file and a 3-line .env to boot. # # State persists in /state by default (override with STATE_DIR). It is a # bind mount, so `docker compose down -v` (named-volume wipe) won't touch it — # but it lives inside the project dir, so keep it clear of `git clean -dffx`. x-state: &state-mount ${STATE_DIR:-./state}:/var/sub-wave services: # ------------------------------------------------------------------------- # CADDY — public edge, the only service bound to a host port # ------------------------------------------------------------------------- caddy: image: ghcr.io/perminder-klair/subwave-caddy:${SUBWAVE_VERSION:-latest} build: context: . dockerfile: docker/Dockerfile.caddy container_name: sub-wave-caddy restart: unless-stopped depends_on: - web - controller - broadcast ports: - "${CADDY_PORT:-7700}:80" volumes: - caddy-data:/data - caddy-config:/config # ------------------------------------------------------------------------- # BROADCAST — icecast2 endpoint + liquidsoap mixer in one container # ------------------------------------------------------------------------- # The supervisor entrypoint resolves ICECAST_*_PASSWORD on first boot # (writing them to state/icecast-secrets.env for visibility), then launches # icecast2 and liquidsoap together. If either process dies the container # exits and compose restarts the pair as a unit. broadcast: image: ghcr.io/perminder-klair/subwave-broadcast:${SUBWAVE_VERSION:-latest} build: context: . dockerfile: docker/Dockerfile.broadcast container_name: sub-wave-broadcast restart: unless-stopped environment: # All three are optional — leave blank in .env and the image generates # random values on first boot, persisting them to state/icecast-secrets.env. # To rotate: delete that file and restart broadcast + controller (the # controller doesn't actually source the file, but a settings reload # picks up any URL change cleanly). - ICECAST_SOURCE_PASSWORD=${ICECAST_SOURCE_PASSWORD:-} - ICECAST_ADMIN_PASSWORD=${ICECAST_ADMIN_PASSWORD:-} - ICECAST_RELAY_PASSWORD=${ICECAST_RELAY_PASSWORD:-} # Keeps the hourly archive paths (%Y-%m-%d/%H-00.mp3) on local wall time. - TZ=${TZ:-Europe/London} extra_hosts: # Lets Liquidsoap fetch Subsonic stream URLs that point at host services # (e.g. NAVIDROME_URL=http://host.docker.internal:4533) without leaking # the lookup to public DNS. - "host.docker.internal:host-gateway" volumes: - *state-mount - ${STATE_DIR:-./state}/logs:/var/log/liquidsoap healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:7702/status-json.xsl > /dev/null"] interval: 5s timeout: 3s retries: 12 start_period: 15s # ------------------------------------------------------------------------- # CONTROLLER — AI DJ brain # ------------------------------------------------------------------------- controller: image: ghcr.io/perminder-klair/subwave-controller:${SUBWAVE_VERSION:-latest} build: context: . dockerfile: docker/Dockerfile.controller container_name: sub-wave-controller restart: unless-stopped depends_on: broadcast: condition: service_healthy environment: # Enables the production-only admin gate: refuses to boot unless # ADMIN_USER + ADMIN_PASS are set in .env. - NODE_ENV=production # Schedule slots are stored by day-of-week + hour; resolveActiveShow() # reads them with date.getHours(). Without TZ the container runs in UTC # and fires shows an hour early during BST. - TZ=${TZ:-Europe/London} # In-container path of the *state-mount below. - STATE_DIR=/var/sub-wave # In-container path of the sounds COPY'd into the image at build time. - SOUNDS_DIR=/sounds # Optional sidecar for Chatterbox + PocketTTS. The tts-heavy service # below is gated by `--profile tts-heavy`; when off, the URL is # unreachable and audio/chatterbox.ts + audio/pocketTts.ts silently # fall back to Piper (same behaviour as the default image today). - TTS_HEAVY_URL=${TTS_HEAVY_URL:-http://tts-heavy:8080} # Per-container CPU/memory for the admin Stats page (GET /system) is read # from the docker-socket-proxy sidecar over TCP — the controller never # touches the raw Docker socket. Unset this to disable the panel. - DOCKER_HOST=tcp://docker-socket-proxy:2375 env_file: # All controller config (Navidrome creds, LLM keys, ADMIN_*, etc.) lives # in the root .env. Vars not set are simply absent — settings.json takes # over for everything the first-run wizard manages. - ./.env extra_hosts: - "host.docker.internal:host-gateway" volumes: - *state-mount # ------------------------------------------------------------------------- # DOCKER-SOCKET-PROXY — locked-down Docker API for the Stats system panel # ------------------------------------------------------------------------- # Owns the real /var/run/docker.sock and exposes a read-only, GET-only slice # of the Docker Engine API over TCP (CONTAINERS section only; POST and every # other section refused by default). The controller reads per-container # CPU/memory through it via DOCKER_HOST=tcp://docker-socket-proxy:2375, so the # controller image itself never holds the socket. No host port — only # reachable on the internal compose network. Optional: remove this service # (and the controller's DOCKER_HOST above) to drop the Stats system panel. docker-socket-proxy: image: ghcr.io/tecnativa/docker-socket-proxy:0.3.0 container_name: sub-wave-docker-proxy restart: unless-stopped environment: - CONTAINERS=1 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro # ------------------------------------------------------------------------- # WEB — Next.js listener UI # ------------------------------------------------------------------------- web: image: ghcr.io/perminder-klair/subwave-web:${SUBWAVE_VERSION:-latest} build: context: . dockerfile: web/Dockerfile args: # Needed at BUILD time for statically-rendered routes (robots, sitemap, # /listen, /landing) — Next bakes their metadata at build. - SITE_URL=${SITE_URL:-} # NEXT_PUBLIC_* is inlined into the client bundle at BUILD time. - NEXT_PUBLIC_GA_ID=${NEXT_PUBLIC_GA_ID:-} container_name: sub-wave-web restart: unless-stopped depends_on: - controller environment: - NODE_ENV=production - SUBWAVE_HOMEPAGE=${SUBWAVE_HOMEPAGE:-player} # Also needed at RUNTIME: the homepage (/) is force-dynamic, so its # share-card tags render per-request. Define SITE_URL once in .env and # both build-arg and runtime env pick it up. - SITE_URL=${SITE_URL:-} # Server-side base URL for generateMetadata on the force-dynamic homepage # to read the live station name from the controller. Internal compose # network name — not exposed to the browser (which uses /api via Caddy). - CONTROLLER_INTERNAL_URL=http://controller:7701 # ------------------------------------------------------------------------- # TTS-HEAVY (optional) — sidecar for Chatterbox + PocketTTS # ------------------------------------------------------------------------- # Heavy PyTorch engines pulled out of the controller image so operators on # the pre-built ghcr.io images can opt into them without a custom rebuild # (issue #103). NOT started by default. Enable with: # # docker compose --profile tts-heavy up -d # # The controller is wired with TTS_HEAVY_URL pointing here unconditionally; # when the profile is off the URL is unreachable and chatterbox.ts / # pocketTts.ts report unavailable, so the dispatcher falls back to Piper — # same behaviour as the default image today. # # See docker/Dockerfile.tts-heavy + docker/tts-heavy/server.py. tts-heavy: image: ghcr.io/perminder-klair/subwave-tts-heavy:${SUBWAVE_VERSION:-latest} build: context: . dockerfile: docker/Dockerfile.tts-heavy args: # Optional — bake the gated PocketTTS cloning weights into the image at # build time. Usually unnecessary: setting HF_TOKEN in `environment` # below enables cloning at runtime (lazy download) without it. HF_TOKEN: ${HF_TOKEN:-} # Install the CLAP audio-embedding stack (torch + transformers + # onnxruntime, ~1.5GB) into the analyzer venv — powers "sounds-like" # audio similarity + sonic journeys. Only consulted on a source build # (`docker compose build`); the published image already bakes CLAP in, # so a plain pull gets it for free. Lazy-loaded at runtime, so it costs # disk only until the analyze pass actually runs. WITH_CLAP: ${WITH_CLAP:-1} # Demucs vocal-activity ranges — same story as WITH_CLAP: baked into the # published image, defaulted on for source builds so they match. Source # build only; lazy-loaded at runtime. WITH_DEMUCS: ${WITH_DEMUCS:-1} # GPU opt-in: point the Chatterbox venv's torch install at a CUDA wheel # index to build a GPU-capable image. Defaults to CPU wheels; override # with e.g. CHATTERBOX_TORCH_INDEX_URL=https://download.pytorch.org/whl/cu124 # and layer on docker-compose.tts-heavy-gpu.yml (device reservation + # TTS_HEAVY_DEVICE=cuda). Source build only. See docs/gpu-tts.md. CHATTERBOX_TORCH_INDEX_URL: ${CHATTERBOX_TORCH_INDEX_URL:-https://download.pytorch.org/whl/cpu} # amd64-only image (heavy PyTorch stack); pinned so it runs under emulation # on arm64 hosts. The other services are multi-arch and auto-select. platform: linux/amd64 container_name: sub-wave-tts-heavy restart: unless-stopped profiles: ["tts-heavy"] environment: # 'cpu' or 'cuda'. The default image is CPU-only — cuda needs a GPU # host + nvidia runtime; the server gracefully falls back to cpu when # CUDA isn't actually available. - TTS_HEAVY_DEVICE=${TTS_HEAVY_DEVICE:-cpu} # Default voice id used by PocketTTS when a persona doesn't override it. - POCKET_TTS_VOICE=${POCKET_TTS_VOICE:-alba} # Optional — enables PocketTTS zero-shot voice CLONING (issue #238). The # cloning weights (kyutai/pocket-tts) are gated on Hugging Face; without a # token the engine loads the open weights and cloned .wav voices revert to # a built-in. Accept the model terms on huggingface.co/kyutai/pocket-tts, # then put HF_TOKEN=hf_... in your root .env. Built-in voices need no token. - HF_TOKEN=${HF_TOKEN:-} # Optional — force CLAP audio embeddings on for the whole analyze pass. # Usually unnecessary: the admin "sounds-like" toggle drives this per # request, and the published image already carries CLAP. Set # ANALYZE_AUDIO_EMBEDDING=1 in your root .env only to always-on it. # CLAP_MODEL picks the transformers checkpoint; CLAP_MODEL_PATH points at # a pre-exported ONNX audio encoder instead. - ANALYZE_AUDIO_EMBEDDING=${ANALYZE_AUDIO_EMBEDDING:-} - CLAP_MODEL=${CLAP_MODEL:-} - CLAP_MODEL_PATH=${CLAP_MODEL_PATH:-} volumes: # Same shared mount as the controller — the sidecar writes WAVs into # /var/sub-wave/voice/* and the controller hands the path to Liquidsoap. - *state-mount # Persist the per-engine Hugging Face caches across container recreates. # Weights download at boot (the workers load the model before reporting # ready) — without these volumes that multi-GB fetch repeats on every # `up -d --build` / image pull / update. With them it happens once. - tts-heavy-chatterbox-cache:/opt/chatterbox/hf-cache - tts-heavy-pocket-cache:/opt/pocket-tts/hf-cache # CLAP weights for the analyzer (only populated when audio embeddings # are enabled and no local CLAP_MODEL_PATH is given). - tts-heavy-analyzer-cache:/opt/analyzer/hf-cache volumes: caddy-data: caddy-config: tts-heavy-chatterbox-cache: tts-heavy-pocket-cache: tts-heavy-analyzer-cache: