# 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: <JELLYFIN_API_TOKEN>" \
     -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.
