Reference media server stack
Two compose stacks live on CT 102 (media-server):
/opt/stacks/media/docker-compose.yml, the 13-container arr stack: gluetun, qbittorrent, sonarr, radarr, prowlarr, flaresolverr, overseerr, bazarr, recyclarr, cleanuparr, audiobookshelf, calibre-web, shelfarr/mnt/storage/appdata/justinkrystal-media/docker-compose.yml, the 3-container justinandkrystal site stack
CRITICAL QUIRK 1 — compose project name: arr stack project is media-server, NOT media (the dirname). Always pass -p media-server to compose commands or they target the wrong project and report ghost orphans.
CRITICAL QUIRK 2 — gluetun netns recreation: every service in the arr stack uses network_mode: container:gluetun. If gluetun is recreated (e.g. you change its ports: block), every dependent container loses its network namespace and must be docker restarted. New containers created against the old gluetun ID will fail to start with "joining network namespace of container: No such container" and must be compose rm -sf then compose up -d again.
Gluetun secrets: /opt/stacks/media/.env (chmod 600), externalized 2026-04-27.
Service config pointers¶
| Service | Port | API key location | Notes |
|---|---|---|---|
| qbittorrent | 8080 | (web UI password) | port-forward auto-pushed by gluetun |
| sonarr | 8989 | docker exec sonarr cat /config/config.xml <ApiKey> |
|
| radarr | 7878 | same pattern in radarr container |
|
| prowlarr | 9696 | same pattern in prowlarr container |
feeds Sonarr/Radarr via app sync |
| flaresolverr | 8191 | n/a | tagged on Cloudflare-protected indexers |
| bazarr | 6767 | docker exec bazarr grep apikey: /config/config/config.yaml (first match = own key) |
linked via SignalR feeds, see config.yaml sonarr.apikey/radarr.apikey |
| recyclarr | n/a | n/a (reads /config/configs/*.yml) | sync: docker exec recyclarr recyclarr sync |
| cleanuparr | 11011 | UI-managed | first-run wizard sets admin + arr/qbit links |
| overseerr | 5055 | UI-managed | host-network, NOT behind gluetun |
| audiobookshelf | 13378 | UI-managed | |
| calibre-web | 8083 | UI-managed | |
| shelfarr | 5056 (mapped to 80) | UI-managed |
Prowlarr indexer fleet (as of 2026-05-03)¶
7 working public indexers: 1337x, The Pirate Bay, Knaben (meta-aggregator), EZTV, YTS, TorrentGalaxyClone, LimeTorrents. Plus TorrentsCSV (degraded). 1337x and TPB tagged with FlareSolverr (id=2 in Prowlarr proxies).
Tried but blocked at gluetun VPN exit IP: kickasstorrents (403 on every mirror), Internet Archive (timeout). Don't waste time re-adding without rotating VPN exit.
Recyclarr / TRaSH¶
Configs in /opt/stacks/media/recyclarr/configs/:
- web-1080p.yml (Sonarr) → quality profile "WEB-1080p" + 37 custom formats
- hd-bluray-web.yml (Radarr) → quality profile "HD Bluray + WEB" + 39 custom formats
API keys baked into the YAML (Sonarr/Radarr keys are embedded literally). Re-sync after editing: docker exec recyclarr recyclarr sync. Templates list: docker exec recyclarr recyclarr config list templates.
gluetun ↔ qBittorrent port-forward sync (STALL ROOT CAUSE, seen 2026-05-29)¶
Symptom: torrents stuck at 0% / stalledDL despite available seeders; Sonarr/Radarr queue shows size 0 / queued. Cause: qBit's Session\Port (listen port) no longer matches gluetun's ProtonVPN forwarded port, so no inbound peers connect.
- gluetun forwarded port:
docker exec gluetun cat /tmp/gluetun/forwarded_port - qBit listen port:
Session\Portin/opt/stacks/media/qbittorrent/qBittorrent/qBittorrent.conf(or via API). - Sync mechanism: gluetun
VPN_PORT_FORWARDING_UP_COMMANDPOSTssetPreferences {listen_port:{{FORWARDED_PORT}}}to qBit. qBit shares gluetun's netns (network_mode: container:gluetun) and hasWebUI\LocalHostAuth=false, so the in-container 127.0.0.1:8080 call bypasses auth. - Manual fix (no restart, no creds):
docker exec gluetun sh -c 'wget -qO- --post-data="json={\"listen_port\":<PORT>}" http://127.0.0.1:8080/api/v2/app/setPreferences'then confirm via/api/v2/app/preferences. - Durable fix (INSTALLED 2026-05-29):
qbit-vpn-port-sync.timeron media-server runs/opt/stacks/media/qbit_vpn_port_sync.shevery 2 min (+90s after boot). Idempotent reconcile: reads gluetun forwarded_port, compares to qBit listen_port, writes only on mismatch. Self-heals reboots + VPN reconnects + port rotation, independent of gluetun's flaky up-command. No container recreate needed. Source-of-truth copies in forge atscripts/integrations/media-server/. Logs to journal tagqbit-port-sync(silent when already in sync). Verified self-heal: forced bogus port -> corrected next run. - (Legacy note) the RUNNING gluetun still has the old single-shot up-command; compose ~line 22 has a 12x retry-loop version not yet applied. Moot now that the timer reconciles, but recreating gluetun would apply it (WARNING: netns dependency means every arr container must be
docker restarted after — see Quirk 2). - Read qBit state without WebUI creds: run any
/api/v2/...GET viadocker exec gluetun wget -qO- http://127.0.0.1:8080/...(localhost-bypass). The qBit WebUI user isjustinwieb; the cred stored in Sonarr's download-client config (root) is stale and rejected.
Plex library update¶
Radarr (id=3) and Sonarr (id=3) each have a "Plex Server" notification (PlexServer implementation) pointed at 192.168.86.73:32400. Triggers on import, upgrade, rename, delete. This tells Plex to scan the relevant library section immediately after Radarr/Sonarr changes files, so new content appears without waiting for Plex's periodic scan. Added 2026-05-26.
Telegram notifications¶
Sonarr + Radarr each have a built-in Telegram notification connector (id=2 in each /api/v3/notification) named "Forge Telegram (notify-out)", wired to @forge_notify_outbound_bot (token in ~/.forge-secrets/telegram-notify-outbound.env, chat 7110798439). Triggers: grab, download, upgrade, import-complete, manual-interaction-required, health issue/restored.
Cleanuparr¶
Stores everything in SQLite at /opt/stacks/media/cleanuparr/cleanuparr.db. No plain config file. UI-only setup; can't be bootstrapped via API ("Setup required" error until wizard completes).