ARRFLIX/docs/12-dev-instance.md

7.1 KiB

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:
    curl -k -H "X-Emby-Token: $DEV_TOKEN" \
      https://dev.arrflix.s8n.ru/Branding/Configuration > /tmp/branding.json
    
  2. POST to prod:
    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:

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.