How to lift, check, and tear down the full FUNK stack on a single machine.
All compose commands assume you're at the repo root: /home/furenku/punksnotdev/proyectos/FUNK/dev/FUNK.
The dev overrides attach auth and radio to a shared external bridge so the
media plane's radio service can resolve auth by DNS name. Create it once:
docker network create funk_dev(Prod does not use this — the planes live on separate hosts and the radio service reaches auth via a public HTTPS URL.)
cp -n infra/env/control.dev.env.example infra/env/control.dev.env # first time only
docker compose --env-file infra/env/control.dev.env \
-f infra/compose/compose.control.yml \
-f infra/compose/compose.control.dev.override.yml \
up -dcp -n infra/env/media.dev.env.example infra/env/media.dev.env # first time only
docker compose --env-file infra/env/media.dev.env \
-f infra/compose/compose.media.yml \
-f infra/compose/compose.media.dev.override.yml \
up -dWhen no schedule is applied, liquidsoap broadcasts white-noise fallback so HLS still has segments to serve. PUT a schedule via the radio API (next section) to play real audio.
ADMIN_TOKEN=dev_admin_bootstrap_change_me # matches control.dev.env.example
# Mint a service credential for the consumer. `scopes` is required — there is
# no implicit "everything" tier. See docs/CREDENTIAL_SCOPES.md.
TOKEN=$(curl -s -X POST http://localhost:7401/v1/credentials \
-H "authorization: Bearer $ADMIN_TOKEN" \
-H "content-type: application/json" \
-d '{"label":"dev consumer","scopes":["radio:read","radio:schedule","storage:write"]}' | jq -r .token)
# Apply a one-entry schedule.
#
# ⚠ audio_url must be on the SCHEDULE_AUDIO_URL_ALLOWLIST, which defaults to
# FUNK's own storage service. Upload the file first and schedule the resulting
# key — an arbitrary origin like https://example.com/sample.mp3 is rejected 400.
KEY=$(curl -s -X POST http://localhost:7402/uploads \
-H "authorization: Bearer $TOKEN" \
-F "file=@./sample.mp3;type=audio/mpeg" | jq -r .key)
curl -s -X PUT http://localhost:7403/v1/radio/schedule \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d "{\"entries\":[{\"audio_url\":\"http://storage:4002/files/$KEY\",\"title\":\"test\"}]}"Host-published dev ports live in the reserved 74xx band so they never
collide with other local stacks. Container-internal ports are unchanged; the
table below shows the host (left) → internal mapping where they differ. The
ports come from infra/env/*.dev.env (*_HOST_PORT).
| Service | URL | Internal | Notes |
|---|---|---|---|
| HLS playlist | http://localhost:7488/hls/master.m3u8 | 8080 | 3-bitrate ladder |
| Icecast status | http://localhost:7480 | 8000 | Source mount: funk.mp3 |
| Liquidsoap telnet | localhost:7423 | 1234 | echo help | nc 127.0.0.1 7423 |
| Live harbor | localhost:7481 | 8001 | Mount: live |
| Breaking harbor | localhost:7482 | 8002 | Mount: breaking |
| Radio API | http://localhost:7403 | 4003 | Hono / Bun |
| Auth API | http://localhost:7401 | 4001 | Hono / Bun |
| Storage API | http://localhost:7402 | 4002 | Hono / Bun |
| Object store (S3) | http://localhost:7490 | 9000 | RustFS. S3 endpoint / presigned URLs |
| Postgres (host) | localhost:7432 | 5432 | Only exposed in dev override |
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E 'funk-(control|media)'
curl -s -o /dev/null -w "hls: %{http_code}\n" http://localhost:7488/hls/master.m3u8
curl -s -o /dev/null -w "auth: %{http_code}\n" http://localhost:7401/health
curl -s -o /dev/null -w "storage: %{http_code}\n" http://localhost:7402/health
curl -s -o /dev/null -w "radio: %{http_code}\n" http://localhost:7403/healthExpect ~9 long-lived containers reporting healthy: 4 in control (postgres, rustfs, auth, storage; rustfs-init runs once and exits) and 5 in media (icecast, liquidsoap, hls, nginx, radio).
# Control plane
docker compose --env-file infra/env/control.dev.env \
-f infra/compose/compose.control.yml \
-f infra/compose/compose.control.dev.override.yml \
down
# Media plane
docker compose --env-file infra/env/media.dev.env \
-f infra/compose/compose.media.yml \
-f infra/compose/compose.media.dev.override.yml \
downAdd -v to either command to wipe volumes (postgres data, object-store contents, applied schedule, recordings).
⚠ -v also deletes control_minio_data, the pre-2026-08-14 MinIO volume kept as the rollback path for the RustFS swap. Drop -v if you still want that rollback.
-
The object store is RustFS, but its DNS name is still
minio. MinIO was replaced byrustfs/rustfs:1.0.0-rc.1on 2026-08-14. The compose service is namedrustfs; it keepsminioas a network alias on bothcontrol_privateandfunk_dev, because consumers outside this repo bakehttp://minio:9000/...into playout URLs and deployed env vars. Userustfsin anything new. Differences that bite:- No web console. RustFS 1.0.0-rc.1 serves the S3 API only;
:9001answers with S3 XML, andRUSTFS_CONSOLE_ENABLE=truedoes not change that. There is nolocalhost:7491any more. Administer withmc:docker run --rm --network funk-control_control_private --entrypoint /bin/sh minio/mc:latest -c \ 'mc alias set s3 http://rustfs:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" && mc ls --recursive s3/wtda-media' - No default CORS policy. MinIO shipped a permissive one; RustFS ships none. A plain
<audio src>tag is unaffected, butfetch(),hls.jsand Web Audio reads of a presigned URL fail their preflight without one.rustfs-initapplies a bucket CORS config fromS3_CORS_ALLOWED_ORIGINSto every bucket it manages. Narrow that from*in prod. mc mb --ignore-existingprints "Bucket created successfully" even when the bucket already exists. RustFS returns success rather thanBucketAlreadyOwnedByYou. The call is still idempotent and does not touch objects — just do not read that line as proof of an empty store.- Health endpoint is
/health, not/minio/health/live. - Verified equivalent to MinIO on 2026-08-14: SigV4 presigned GET (same-host, cross-host,
https-signed/http-delivered, expiry, tamper-rejection,
Range,response-content-typeoverrides), anonymous bucket policies,PutObjectwithx-amz-meta-*,ListObjectsV2,HeadObject, andmc mirror/mc diff. - Env vars are now
S3_ACCESS_KEY/S3_SECRET_KEY/S3_HOST_PORT. The oldMINIO_ROOT_USER/MINIO_ROOT_PASSWORD/MINIO_HOST_PORTnames still work as a fallback, so an env file can be renamed separately from the image swap. - Deployed environments are not migrated. See RUSTFS_MIGRATION.md.
- No web console. RustFS 1.0.0-rc.1 serves the S3 API only;
-
Presigned download URLs point at whatever
S3_PUBLIC_ENDPOINTresolves to. Keep it set to a host the browser can reach (defaulthttp://localhost:7490in dev). A SigV4 presigned URL is bound to theHostheader it was signed for, so any proxy in front of the store must passHostthrough unchanged. The URL scheme is not signed, so signing forhttps://…and terminating TLS in front of a plain-HTTP store works. -
Scheduled programme audio must be fetchable from inside
funk-media-liquidsoap-1. A presigned URL is signed for exactly one host, and host-published ports (localhost:74xx, the docker bridge gateway, the host LAN IP) are unreachable from containers on machines whose firewall blocks bridge→host traffic — that is the usual reason a schedule "applies" but listeners hear only the white-noise fallback. Two dev arrangements make playout URLs work:STORAGE_PROXY_DOWNLOADS=true(control env) →GET /files/<key>streams the bytes through the storage API instead of 302-ing to a presigned MinIO URL, sohttp://storage:4002/files/<key>works from liquidsoap, the host, and browsers alike. Leave itfalsein prod, where the S3 endpoint is publicly reachable.liquidsoapand the object store both join the shared externalfunk_devbridge, so consumers keeping their own bucket here can hand FUNK plainhttp://minio:9000/<bucket>/<key>playout URLs.miniois a compatibility DNS alias for therustfsservice — see the object-store gotcha below. Diagnose withdocker exec funk-media-liquidsoap-1 python3 -c "import urllib.request;print(urllib.request.urlopen('<url>',timeout=5).status)"— the image has python3 but no curl/wget.
-
Radio reaches auth via the shared
funk_devbridge (see "One-time setup" above). The host-gateway approach used previously broke on hosts whose firewall blocks bridge-to-host traffic; the shared external network is more portable. -
Per-host harbor credentials are minted via
POST /v1/radio/live/credentials(live) orPOST /v1/radio/interrupt/live(breaking). The response includesusernameandpassword— pass both to your broadcasting tool as the icecast login. Each credential is validated per-connection via an HTTP callback into radio; revocation (DELETE /v1/radio/live/credentials/:id) takes effect immediately on the next connection attempt. There is no longer a shared harbor password —HARBOR_LIVE_PASSWORDandHARBOR_BREAKING_PASSWORDare gone.RADIO_INTERNAL_SECRETis the new shared secret used only on the internalmedia_privatebridge between liquidsoap and radio. -
HLS CORS headers live in
infra/services/media-nginx/nginx.conf. Each regex location declares its ownadd_headerblock — nginx does not inherit. Editing the file requiresdocker restart funk-media-nginx-1;nginx -s reloaddoes not pick up the change reliably. -
demo/svelte-poc/is a minimal, working contract-reference demo of the current FUNK contract: a SvelteKit server route holdsFUNK_SERVICE_TOKENand proxies/v1/radio/now-playing, while the browser plays the anonymous HLS stream directly. Run it withcd demo/svelte-poc && bun install && bun run dev(port 7270) after pasting a minted service token into its.env— see itsREADME.md. Consumer-side code is still meant to live in a separate repo per ADR-003; this dir is kept only as a living example of the contract.