Jellyfin — Setup
Media server with NVIDIA GPU-accelerated transcoding. Runs on a dedicated Debian LXC (114) with GTX 750 Ti passthrough. Accessible at https://jellyfin.eva-00.network.
Infrastructure
| Host | LXC ID | IP | URL | Resources |
|---|---|---|---|---|
| Debian 12 LXC | 114 | 192.168.1.114 | https://jellyfin.eva-00.network | 6 cores, 8 GB RAM, 50 GB disk |
GPU Capabilities
| Feature | GTX 750 Ti (GM107, Maxwell 1st gen) |
|---|---|
| H.264 encode | Yes |
| H.264 decode | Yes |
| HEVC (H.265) | No — CPU fallback |
| VP9 | No — CPU fallback |
| AV1 | No — CPU fallback |
| VRAM | 2 GB |
| Concurrent 1080p H.264 transcodes | ~2-3 |
| NVIDIA driver | 535.154.05 |
| Docker runtime | nvidia |
GPU is passed through at the LXC level (not VM). The host-side GPU setup is managed by a separate playbook and workflow.
GPU Passthrough Guide
This section documents every step needed to pass an NVIDIA GPU from the Proxmox host into an unprivileged LXC. All steps are codified in IaC (playbooks below), but this guide explains what each step does and why.
Prerequisites
- NVIDIA GPU physically installed in the Proxmox host
- LXC must be privileged or have the correct cgroup device rules (unprivileged works with the config below)
- LXC must be stopped before modifying its conf file
Step 1: Install NVIDIA driver on the Proxmox host
The host needs the kernel driver so the GPU device nodes (/dev/nvidia*) are created.
# On chizuru (Proxmox host)
# Add non-free repos (needed for NVIDIA packages)
sed -i 's/bookworm main$/bookworm main contrib non-free non-free-firmware/' /etc/apt/sources.list
# Install prerequisites + driver
apt update
apt install -y pve-headers build-essential dkms nvidia-driver nvidia-smi
# Load the UVM module (needed for CUDA/compute)
modprobe nvidia-uvm
echo "nvidia-uvm" > /etc/modules-load.d/nvidia.conf
# Verify
nvidia-smi
After install, four device nodes must exist:
| Device | Major | Purpose |
|---|---|---|
/dev/nvidia0 |
195 | GPU device |
/dev/nvidiactl |
195 | GPU control |
/dev/nvidia-uvm |
236 | Unified Virtual Memory |
/dev/nvidia-uvm-tools |
236 | UVM tools |
Step 2: Configure LXC GPU passthrough
Edit the LXC conf on the Proxmox host to allow access to the GPU devices and bind-mount them into the container.
# On chizuru — edit /etc/pve/lxc/114.conf
# Add these lines (the playbook uses blockinfile):
# cgroup2 device allow rules (major device numbers)
lxc.cgroup2.devices.allow: c 195:* rwm
lxc.cgroup2.devices.allow: c 236:* rwm
# Bind mount GPU devices into the LXC
lxc.mount.entry: /dev/nvidia0 dev/nvidia0 none bind,optional,create=file
lxc.mount.entry: /dev/nvidiactl dev/nvidiactl none bind,optional,create=file
lxc.mount.entry: /dev/nvidia-uvm dev/nvidia-uvm none bind,optional,create=file
lxc.mount.entry: /dev/nvidia-uvm-tools dev/nvidia-uvm-tools none bind,optional,create=file
Key details:
c 195:* rwm— allows character device major 195 (nvidia) with read/write/mknodc 236:* rwm— allows character device major 236 (nvidia-uvm)bind,optional,create=file— bind mounts the host device;optionalmeans LXC starts even if device is absent- These go in the LXC conf file, not inside the container
Step 3: Install matching NVIDIA driver inside the LXC
The driver version inside the LXC must exactly match the host driver version. Since the LXC shares the host kernel, install with --no-kernel-modules.
# Inside LXC 114
# Check host driver version first
# (on host: nvidia-smi --query-gpu=driver_version --format=csv,noheader)
# Example: 535.154.05
# Download the exact matching version
wget https://us.download.nvidia.com/XFree86/Linux-x86_64/535.154.05/NVIDIA-Linux-x86_64-535.154.05.run
chmod +x NVIDIA-Linux-x86_64-535.154.05.run
# Install userspace libraries only (no kernel modules!)
./NVIDIA-Linux-x86_64-535.154.05.run --no-kernel-modules --silent --no-questions
# Verify GPU is visible
nvidia-smi
Why --no-kernel-modules: The LXC uses the host's kernel and kernel modules. Installing kernel modules inside the container would fail (no kernel headers) and is unnecessary.
Step 4: Install nvidia-container-toolkit for Docker
This allows Docker containers (like Jellyfin) to access the GPU via the nvidia runtime.
# Inside LXC 114
# Add NVIDIA container toolkit repo
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
> /etc/apt/sources.list.d/nvidia-container-toolkit.list
apt update && apt install -y nvidia-container-toolkit
# Register the nvidia runtime with Docker
nvidia-ctk runtime configure --runtime=docker
systemctl restart docker
Step 5: Configure Docker Compose for GPU
The Jellyfin container uses runtime: nvidia and resource reservations:
services:
jellyfin:
image: jellyfin/jellyfin:latest
runtime: nvidia
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
- NVIDIA_VISIBLE_DEVICES=all
Step 6: Media bind mount (Proxmox level)
Jellyfin reads media from mediabot's data directory via a Proxmox mount point:
# On chizuru — added to /etc/pve/lxc/114.conf:
# mp0: <mediabot-rootfs-path>/data,mp=/data/media,ro=1
#
# The playbook dynamically resolves the rootfs path:
# grep -oP 'rootfs:\s+\K[^,]+' /etc/pve/lxc/113.conf → e.g. local-lvm:vm-113-disk-0
# pvesm path local-lvm:vm-113-disk-0 → e.g. /dev/pve/vm-113-disk-0
After adding the mount point, restart LXC 114 for it to take effect.
Verification checklist
# On Proxmox host:
nvidia-smi # GPU visible, driver loaded
ls -la /dev/nvidia* # All 4 device nodes exist
# Inside LXC 114:
nvidia-smi # GPU visible (same output as host)
docker run --rm --runtime=nvidia nvidia/cuda:12.0.0-base-ubuntu22.04 nvidia-smi
# GPU visible inside Docker container
# Inside Jellyfin:
# Admin → Playback → Transcoding → NVIDIA NVENC should be available
IaC automation
Both playbooks automate all of the above:
| Playbook | What it does | When to run |
|---|---|---|
jellyfin-host-gpu.yml |
Steps 1-2 + 6 (host driver, LXC conf, bind mount) | First-time setup or driver update |
jellyfin.yml |
Steps 3-5 (LXC driver, nvidia-container-toolkit, Docker, Jellyfin deploy) | Every deploy |
Trigger via Forgejo Actions → workflow_dispatch for the host GPU playbook, or automatic on push for the Jellyfin deploy.
Observability
Logs
Jellyfin logs are collected via Grafana Alloy Docker discovery and shipped to Loki.
| Query | Purpose |
|---|---|
{container="jellyfin"} |
All container output |
{container="jellyfin"} \|= "error" |
Errors only |
{container="jellyfin"} \|= "transcode" |
Transcoding activity |
{container="jellyfin"} \|= "hardware" |
Hardware acceleration messages |
Access: Grafana → Explore → Loki → Enter query
Health
| Endpoint | Expected |
|---|---|
http://192.168.1.114:8096/health |
Healthy |
IaC
| Artifact | Path |
|---|---|
| Playbook (deploy) | ansible/playbooks/jellyfin.yml |
| Playbook (GPU host setup) | ansible/playbooks/jellyfin-host-gpu.yml |
| Workflow (deploy) | .forgejo/workflows/jellyfin.yml |
| Workflow (GPU host setup) | .forgejo/workflows/jellyfin-host-gpu.yml (workflow_dispatch only) |
| Compose | services/jellyfin/docker-compose.yml |
| CSS Themes | services/jellyfin/themes/liquid-glass.css, services/jellyfin/themes/ios18-dark.css, services/jellyfin/themes/mediamanager.css |
Secrets
| Vault Path | Key | Purpose |
|---|---|---|
secret/jellyfin |
api_key |
Jellyfin API key for automated plugin install and branding config |
secret/jellyfin |
oidc_client_id |
PocketID OIDC client ID (created by pocketid-setup workflow) |
secret/jellyfin |
oidc_client_secret |
PocketID OIDC client secret |
Generate an API key after completing the setup wizard: Dashboard → API Keys → Create.
Store it in Vault: vault kv patch secret/jellyfin api_key=<key>
CSS Themes
Two custom CSS themes are available in services/jellyfin/themes/:
| Theme | File | Style |
|---|---|---|
| Liquid Glass | liquid-glass.css |
iOS 26 Liquid Glass light mode — translucent white panels, backdrop blur, blue accents |
| iOS 18 Dark | ios18-dark.css |
iOS 18 dark mode — true black background, no borders, vibrant system colors |
| MediaManager | mediamanager.css |
Monochrome grayscale with MM brand accents (blue/orange/pink) |
The Liquid Glass theme is applied automatically by the playbook via the branding API. To switch themes, update the playbook's lookup('file', ...) path or apply manually via Dashboard → Branding → Custom CSS.
Libraries
| Library | Type | Source | Metadata | Notes |
|---|---|---|---|---|
| Anime | tvshows | /dlbox/normal/anime + /unohana/shoko/anime + /filedump/anime |
Shoko (Shokofin VFS) | All anime TV series across temp + curated archive + filedump |
| Anime Movies | movies | /dlbox/normal/anime + /unohana/shoko/anime + /filedump/anime |
Shoko (Shokofin VFS) | Same paths as Anime; Shokofin filters movies into here |
| Seasonal | tvshows | /dlbox/normal/seasonal |
Shoko (Shokofin VFS) | Current-season anime auto-downloaded by Seanime |
| Movies | movies | /dlbox/normal/movies/general + /filedump/movies |
TMDb | Live-action movies (mediabot + manual archive) |
| TV Shows | tvshows | /dlbox/normal/tv/live-action + /filedump/tv |
TVDB + TMDb | Live-action shows (mediabot + manual archive) |
The Anime / Anime Movies / Seasonal libraries use Shokofin in VFS mode. Shoko Server (LXC 116) indexes files from all four anime locations (registered as Shoko import folders 1, 3, 4, 5) and provides metadata via AniDB. The Anime library is populated by downloading torrents with the anime category in qBittorrent-normal (LXC 110); the Seasonal library is populated by Seanime (LXC 134) auto-downloading current-season anime via the seasonal qbit category.
Anime + Anime Movies share the same three paths. Jellyfin's content type filter, combined with Shokofin's FilterMovieLibraries: True, routes shows into Anime and movies into Anime Movies automatically. Standalone anime movies stay out of the Shows library this way.
Franchise grouping via Jellyfin Collections. Shokofin's CollectionGrouping: Shared auto-builds a Jellyfin Collection for each Shoko Group (franchise), spanning both Anime and Anime Movies. So an "Attack on Titan" Collection holds the TV series + the movies in one browsable view.
The Movies and TV Shows libraries are populated by mediabot (LXC 113), which writes to /dlbox/normal/movies/general and /dlbox/normal/tv/live-action. Each library also includes a folder under /filedump for content the user has manually archived to the 12TB HDD.
Anime subfolders managed by mediabot (/dlbox/normal/movies/anime, /dlbox/normal/tv/anime) are intentionally not added to the Movies / TV Shows libraries — they remain Shoko's responsibility.
Realtime anime pipeline (Seanime → Shoko → Shokofin → Jellyfin)
New episodes appear in Jellyfin within seconds of qBittorrent finishing the download. The chain has four moving parts, each of which must be configured correctly — if any one is wrong, episodes get stuck waiting for the next scheduled scan.
AniList watchlist
→ Seanime auto-downloader (poll every 20 min, sends to qbit-normal)
→ qbit-normal downloads to /dlbox/normal/seasonal
→ Shoko WatchForNewFiles (inotify on the import folder) indexes the file
→ Shoko emits SignalR file/refresh event
→ Shokofin (connected to Shoko's SignalR) regenerates the VFS symlink
AND triggers a Jellyfin metadata refresh on the affected library
→ Jellyfin RealtimeMonitor on /config/Shokofin/VFS/<uuid>/ picks up the
new symlink and registers the episode
Required Shokofin plugin settings (set by ansible/playbooks/jellyfin.yml):
| Setting | Value | Effect |
|---|---|---|
SignalR_AutoConnectEnabled |
true |
Shokofin keeps a SignalR connection to Shoko Server. Without this, no realtime events flow. |
SignalR_RefreshEnabled |
true |
Plugin-level switch for refresh events. Off by default. |
SignalR_FileEvents |
true |
Receive file-level events (new file, deleted, moved). |
Per-library IsFileEventsEnabled |
true |
Library responds to file events → VFS symlink regen. Defaults to false on auto-created libraries. |
Per-library IsRefreshEventsEnabled |
true |
Library responds to refresh events → Jellyfin scan trigger. Defaults to false. This is the easiest one to miss — without it the VFS gets the symlink but Jellyfin doesn't scan it until the next scheduled run. |
Required Jellyfin library settings (also set by the playbook):
| Setting | Value | Effect |
|---|---|---|
EnableRealtimeMonitor |
true |
Jellyfin's inotify watcher on the library path. Fires on filesystem changes. |
AutomaticRefreshIntervalDays |
30 |
Background safety-net scan; should rarely actually catch new files because the realtime pipeline gets there first. |
Verify the connection is live with:
pct exec 114 -- docker logs --since 5m jellyfin | grep SignalRConnectionManager
You should see Connected to Shoko Server. after the plugin starts (or after a config flip). If you see repeated Connecting to… lines without a Connected, Shoko at 192.168.1.116:8111 is unreachable or the Shokofin ApiKey is wrong.
MyAnimeList Plugin
The playbook automatically adds the MAL plugin repository and installs the plugin. MAL is available as a fallback metadata provider but is not currently used — both libraries use Shoko.
Repository URL: https://raw.githubusercontent.com/ryandash/jellyfin-plugin-myanimelist/refs/heads/main/manifest.json
SSO with PocketID
Jellyfin uses the SSO-Auth plugin for single sign-on via PocketID. The full SSO setup is automated via API — no manual UI configuration needed.
How it works
- pocketid-setup workflow: Creates the
jellyfinOIDC client in PocketID with callback URLhttps://jellyfin.eva-00.network/sso/OID/redirect/PocketID, stores credentials in Vault - jellyfin workflow: Installs the SSO-Auth plugin, configures it via API, adds the login button, and disables password login
SSO plugin configuration (via API)
The SSO-Auth plugin config is set via the Jellyfin plugin configuration API:
POST /Plugins/505ce9d1-d916-42fa-86ca-673ef241d7df/Configuration
The plugin GUID is 505ce9d1-d916-42fa-86ca-673ef241d7df. Key configuration:
| Setting | Value |
|---|---|
| OidEndpoint | https://auth.eva-00.network |
| OidClientId | From Vault: secret/jellyfin → oidc_client_id |
| OidSecret | From Vault: secret/jellyfin → oidc_client_secret |
| Enabled | true |
| EnableAuthorization | true |
| EnableAllFolders | true |
| RoleClaim | groups |
| AdminRoles | ["admin"] (maps PocketID admin group to Jellyfin admin) |
| OidScopes | ["openid", "profile", "email", "groups"] |
| SchemeOverride | https |
| DefaultUsernameClaim | preferred_username |
Login button (via Branding API)
The SSO-Auth plugin does not automatically add a login button. It must be injected via the Branding API's LoginDisclaimer field:
POST /System/Configuration/branding
{
"LoginDisclaimer": "<form action=\"https://jellyfin.eva-00.network/sso/OID/start/PocketID\"><button class=\"raised block emby-button button-submit\" style=\"margin-top:1em;\">Sign in with PocketID</button></form>"
}
Disabling password login
Password login is disabled in two ways:
- Per-user: Set each user's
AuthenticationProviderIdtoJellyfin.Plugin.SSO.SSOAuthenticationProviderPluginviaPOST /Users/{id}/Policy - UI: Hide the username/password form via CSS in the Branding
CustomCssfield:
#loginPage .manualLoginForm .inputContainer,
#loginPage .manualLoginForm > .button-submit,
#loginPage a[href="#/forgotpassword.html"] {
display: none !important;
}
Admin role mapping
The SSO plugin's AdminRoles setting maps PocketID groups to Jellyfin admin. Users in the PocketID admin group get Jellyfin admin on SSO login. Without this, the plugin resets IsAdministrator to false on each login.
Important limitations
- Browser only: SSO login works in the Jellyfin web UI. Mobile apps (iOS/Android) and desktop clients do not support SSO — use QuickConnect instead (see below)
- First login creates a new Jellyfin user: The SSO plugin auto-creates a Jellyfin user on first OIDC login using the
preferred_usernameclaim from PocketID - Existing users: If you already created a local user (e.g.
holo), the SSO plugin will create a separate user. You can merge them by setting the same username in PocketID
QuickConnect (Mobile & Desktop Clients)
QuickConnect allows Jellyfin mobile apps and desktop clients to authenticate without entering a password — useful since these clients don't support SSO.
Enable QuickConnect (one-time)
- Go to Dashboard → Networking (or Dashboard → General depending on Jellyfin version)
- Find QuickConnect and ensure it is Enabled
- Save
How to use QuickConnect
On the client (phone/TV/desktop app):
- Open the Jellyfin app
- Enter the server URL:
https://jellyfin.eva-00.network - On the login screen, tap Quick Connect
- The app will display a 6-digit code
On the web UI (browser):
- Log in to Jellyfin via the web UI (using PocketID SSO or local password)
- Click your user icon (top right) → Quick Connect
- Enter the 6-digit code from the app
- Click Authorize
The app will automatically log in. The authorization is permanent for that device — you don't need to repeat it unless you sign out.
Supported clients
| Client | QuickConnect | SSO | Notes |
|---|---|---|---|
| Web browser | N/A | Yes | Use PocketID SSO button on login page |
| Jellyfin iOS app | Yes | No | Use QuickConnect to authenticate |
| Jellyfin Android app | Yes | No | Use QuickConnect to authenticate |
| Jellyfin Android TV | Yes | No | Use QuickConnect to authenticate |
| Jellyfin Desktop (Electron) | Yes | No | Use QuickConnect to authenticate |
| Jellyfin MPV Shim | No | No | Use local username/password |
| Finamp (music) | Yes | No | Use QuickConnect to authenticate |
| Swiftfin (iOS alt) | Yes | No | Use QuickConnect to authenticate |
Revoking access
To revoke a device's QuickConnect authorization:
- Go to Dashboard → Devices
- Find the device
- Click Delete to revoke its session
Transcoding Configuration
Configure hardware transcoding in the Jellyfin admin dashboard:
- Go to Administration → Playback → Transcoding
- Set hardware acceleration to NVIDIA NVENC
- Enable H.264 hardware encoding and decoding
- Disable HEVC, VP9, and AV1 hardware options (not supported by the 750 Ti — these fall back to CPU automatically)
- Save and restart Jellyfin if prompted
Media Storage
Media is mounted read-only inside the LXC via Proxmox bind mounts. The actual paths live on chizuru's disks; the LXC sees them at the mount points below.
| Mount (inside LXC) | Host source | Access | Used by |
|---|---|---|---|
/dlbox |
/mnt/seedbox (chizuru) |
Read-only | Movies, TV Shows libraries (under /dlbox/normal/...); Anime via Shokofin VFS |
/filedump |
/mnt/filedump (chizuru) |
Read-only | Movies, TV Shows libraries (archived content) |
/unohana |
/mnt/pve/urahara (chizuru) |
Read-only | Archives (anime) via Shokofin VFS |
/urahara |
/mnt/pve/urahara (chizuru) |
Read-only | Reserved |