# 12 — Jellyfin DEV instance for theme experimentation A second Jellyfin container, `jellyfin-dev`, runs alongside prod on nullstone. Same media library (read-only), separate config/cache/users, separate domain. LAN-only by design — you can break it freely without real users (marco, house, guest, 5) noticing. --- ## Architecture diff | Aspect | Prod | Dev | |-------------------|-------------------------------------|-------------------------------------------| | Container | `jellyfin` | `jellyfin-dev` | | Image | `jellyfin/jellyfin:10.10.3` | `jellyfin/jellyfin:10.10.3` (must match) | | Compose path | `/opt/docker/jellyfin/` | `/opt/docker/jellyfin-dev/` | | Config dir | `/home/docker/jellyfin/{config,cache}` | `/home/docker/jellyfin-dev/{config,cache}` | | Media mount | `/home/user/media:/media:ro` | `/home/user/media:/media:ro` (SAME, RO) | | Domain | `arrflix.s8n.ru` | `dev.arrflix.s8n.ru` | | Pi-hole DNS | `dns.hosts` in pihole.toml | `dns.hosts` in pihole.toml (added 2026-05-08) | | Traefik router | `Host(arrflix.s8n.ru)` | `Host(dev.arrflix.s8n.ru)` | | Cert | LE DNS-01 (Gandi) | LE DNS-01 (auto-issued on first request) | | Middleware | `security-headers@file` only | `security-headers@file,no-guest@file` | | WAN exposure | Yes during WAN window (doc 09) | NEVER — LAN-only forever | | Internal port | `8096` | `8096` | | User | `1000:1000` | `1000:1000` | | `userns_mode` | `host` | `host` | | index.html shim | Bind-mounted (doc 10) | None (vanilla shell — clean theme canvas) | | Branding/auth | Configured | Empty — first-run wizard required | The compose file lives in this repo at `compose-dev/docker-compose.yml` and is deployed to nullstone at `/opt/docker/jellyfin-dev/docker-compose.yml`. --- ## How to use 1. Open `https://dev.arrflix.s8n.ru` from any LAN/tailnet box. First visit hits the first-run wizard — create an admin user (use any throwaway name; nothing shared with prod). 2. Add libraries pointing at the same paths prod uses: - `/media/movies` - `/media/tv` The library ROOTS are shared (read-only); dev will rescrape independently into its own `library.db`. That's intentional — dev is a clean slate. 3. Apply a theme via Branding API or via the SPA shim (doc 10) by dropping files into `/opt/docker/jellyfin-dev/web-overrides/` and adding the same bind-mount pattern as prod (currently absent for a clean canvas). 4. Test, watch, break. Prod remains untouched on `arrflix.s8n.ru`. --- ## Theme workflow (dev → prod) When a dev theme is "shipped": 1. **Export branding** from dev: ```bash curl -k -H "X-Emby-Token: $DEV_TOKEN" \ https://dev.arrflix.s8n.ru/Branding/Configuration > /tmp/branding.json ``` 2. **POST to prod**: ```bash curl -k -X POST \ -H "X-Emby-Token: *redacted*" \ -H "Content-Type: application/json" \ --data @/tmp/branding.json \ https://arrflix.s8n.ru/System/Configuration/branding ``` 3. If the theme involves SPA-shim files (custom JS/CSS), `rsync` them from `dev:/opt/docker/jellyfin-dev/web-overrides/` to `prod:/opt/docker/jellyfin/web-overrides/` and hot-reload prod via the bind-mount (no container restart needed for read-only mounts on file change — Jellyfin will serve the new file on next request). Auth tokens for dev are local to the dev instance — they'll be issued by the dev wizard. They DO NOT cross over. --- ## Reset / wipe dev When experiments make a mess: ```bash ssh user@192.168.0.100 cd /opt/docker/jellyfin-dev docker compose down sudo rm -rf /home/docker/jellyfin-dev/config/* /home/docker/jellyfin-dev/cache/* # (use the privileged-userns-host bypass if no sudo: # docker run --rm --privileged --userns=host -v /home/docker:/h alpine \ # sh -c 'rm -rf /h/jellyfin-dev/config/* /h/jellyfin-dev/cache/*') docker compose up -d ``` First-run wizard reappears. The media library is intact (read-only mount, unaffected). --- ## LAN-only enforcement `no-guest@file` middleware (defined in `/opt/docker/traefik/config/dynamic.yml`) restricts source IPs to: - `127.0.0.0/8` - `192.168.0.0/24` (LAN) - `100.64.0.1/32` onyx, `100.64.0.2/32` nullstone, `100.64.0.4/32` office (tailnet) - `82.22.5.233/32` YOU500 home IP - `172.20.0.0/24` docker proxy gateway Anyone outside that list trying `https://dev.arrflix.s8n.ru` from the WAN gets a Traefik 403. Even if a guest tailnet node (100.64.0.3 friend GPU) hits dev, no-guest blocks them — only `tag:admin` and `tag:infra` are allowed. There is **no plan** to expose dev publicly. If you need to test something WAN-shaped, do it on prod inside the WAN window (doc 09) — never widen dev's allowlist. --- ## Risks and non-risks - **Read-only media mount.** Dev cannot write to `/home/user/media`. Theme experiments cannot accidentally rename, delete or scramble files. - **Separate library.db.** Dev rescrapes from scratch. If a metadata experiment in dev produces bad results, it never touches prod metadata. - **Same Traefik instance.** Both routers share the proxy network and the one Traefik. A misconfigured label on dev could *theoretically* shadow prod's router, but the rules are `Host(dev.arrflix.s8n.ru)` vs `Host(arrflix.s8n.ru)` — disjoint. Sanity-check after any compose edit with `curl -kI https://arrflix.s8n.ru/`. - **Same image tag.** Bumping prod to a new Jellyfin version means bumping dev too; do prod first, then sync dev. Never test a version bump on dev and forget to mirror prod — the API surface might drift. - **No shared sessions.** Tokens, users, watch progress, playlists are 100% isolated. A test admin in dev cannot act on prod, and vice versa. --- ## Quick reference ``` # Status ssh user@192.168.0.100 'docker ps --filter name=jellyfin' # Logs ssh user@192.168.0.100 'docker logs jellyfin-dev --tail 100 -f' # Restart ssh user@192.168.0.100 'cd /opt/docker/jellyfin-dev && docker compose restart' # Stop / start ssh user@192.168.0.100 'cd /opt/docker/jellyfin-dev && docker compose down' ssh user@192.168.0.100 'cd /opt/docker/jellyfin-dev && docker compose up -d' # Health check from onyx curl -kI https://dev.arrflix.s8n.ru # expect HTTP/2 302, location: web/ ``` --- ## DNS pin path used The dev hostname was added to Pi-hole's `dns.hosts` array in `/opt/docker/pihole/etc-pihole/pihole.toml` (alongside the existing LAN-only entries) and Pi-hole was restarted to pick up the change. The legacy `custom.list` file is still present but is no longer the authoritative source — `dns.hosts` in `pihole.toml` is what `pihole-FTL` actually consults. If `dev.arrflix.s8n.ru` ever fails to resolve, restart Pi-hole and re-check the `dns.hosts` array.