LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

The Self-Hosted Media Server Stack: Jellyfin, the *arr Apps, and Full Automation

jellyfinplexmedia-serverhomelabself-hostingautomationdockerarr-stack
Contents

There is a specific kind of frustration that comes from opening Netflix to watch a film you remember being there, only to find it’s gone. Or opening HBO Max — sorry, Max — to finish a series, then discovering the app has been rebranded, your watchlist has vanished, and the show was quietly removed because of a tax write-down. Or splitting a streaming bill seven ways across family members and still not having access to everything you want to watch.

The self-hosted media server stack is the answer. Done right, it gives you a streaming interface that looks and feels like Netflix, a library that never loses content, hardware-accelerated transcoding to any device in your house, automatic subtitle downloads, and a request portal where family members can ask for movies they want — and have them appear automatically. All running on hardware you own, with no monthly subscription, no ads, and no DRM.

This guide walks through the complete stack: every service, how they connect, the full Docker Compose configuration, and the step-by-step setup for each component. By the end, you will have a fully automated media pipeline.


Part 1: Why Self-Host Your Media?

Streaming Service Fatigue Is Real

The economics of streaming have fundamentally changed. The subscriber acquisition era — where every platform competed on price and content breadth — is over. What replaced it is a fragmented, increasingly expensive landscape where:

  • Libraries shrink constantly as licensing deals expire or studios pull content for their own platforms
  • Prices increase every 6-12 months, with ad-supported tiers becoming the de facto free tier
  • Content you want is spread across 4-6 different subscriptions
  • Shows get cancelled mid-season; original content disappears in tax restructuring write-downs
  • Password sharing, once tolerated, is now actively policed

The cost of maintaining access to a reasonable cross-platform library now regularly exceeds $80-100/month for a household. And you still don’t own any of it.

Owning Your Media

When you manage your own library, you control what is in it. You rip Blu-rays you own, you archive content before it disappears, and you organize it to your standards. The files sit on your hard drives. Nothing expires, nothing gets removed, and there is no buffering driven by server load on a CDN you share with millions of users.

The self-hosted stack also gives you things no streaming service offers:

  • Transcoding to any device: Old tablet that only plays H.264? The server transcodes H.265 files on the fly. Slow connection? Transcode to a lower bitrate.
  • Offline access: Download to a phone or laptop before a flight, no subscription needed.
  • Family sharing with user accounts: Everyone gets their own profile, watch history, and recommendations. Set content age restrictions per account.
  • Complete metadata control: Movie posters, trailers, director filmographies — all pulled from TheMovieDB and cached locally.

Jellyfin vs Plex vs Emby

The three main media server platforms occupy different positions on the open source / commercial spectrum:

Jellyfin is fully open source under the GPL. There is no account requirement, no phone-home telemetry, no “premium” tier, no ads, and no paywall on any feature. Hardware transcoding, sync, and multi-user support are all free. It is the most aligned with the self-hosting ethos and the recommendation in this guide.

Plex has a polished, consumer-friendly UI and broad client support. The free tier works for basic streaming but restricts hardware transcoding, offline sync, and some mobile clients behind Plex Pass (~$5/month or $120 lifetime). It requires a Plex account for server setup, and the application phones home to Plex’s servers to function. It is a reasonable choice if you prioritize UI polish and have budget for Plex Pass.

Emby sits in the middle. It is partially open source (the server is open, clients are proprietary) and requires Emby Premiere for features like hardware transcoding and mobile downloads. Less community momentum than Jellyfin.

For a new setup, Jellyfin is the clear recommendation: zero cost, fully open, no account required, and the feature gap with Plex has largely closed.

A note on legality: This guide covers managing media files you legally own — ripped Blu-rays, digital purchases, personal recordings. How you acquire your media is your responsibility.


Part 2: The Full Stack — Every Service and Its Role

Before diving into configuration, it helps to understand exactly what each service does and how they connect.

┌─────────────────────────────────────────────────────────────────┐
│                        USER LAYER                               │
│   Jellyseerr (requests)        Jellyfin (streaming)             │
│        │                              ▲                         │
│        │ request movie/show           │ scans /data/media       │
│        ▼                              │                         │
├────────────────────────┬──────────────┴──────────────────────── │
│     MANAGEMENT LAYER   │                                         │
│  Radarr (movies)       │  Sonarr (TV shows)                     │
│       │                │       │                                │
│       └────────────────┤       │                                │
│                        ▼       ▼                                │
│                 Prowlarr (indexer hub)                           │
│                        │                                         │
│                        │  search indexers                        │
│                        ▼                                         │
│              Indexers (1337x, private trackers, etc.)            │
│                        │                                         │
│              Flaresolverr (Cloudflare bypass)                    │
├────────────────────────────────────────────────────────────────┤
│                    DOWNLOAD LAYER                               │
│              qBittorrent (behind gluetun VPN)                   │
│                        │                                         │
│                writes to /data/torrents/                         │
├────────────────────────────────────────────────────────────────┤
│                    SUBTITLE LAYER                               │
│              Bazarr (auto subtitle download)                     │
│       monitors /data/media/, fetches from OpenSubtitles          │
├────────────────────────────────────────────────────────────────┤
│                     STORAGE LAYER                               │
│    /data/torrents/movies    /data/torrents/tv                   │
│    /data/media/movies       /data/media/tv                      │
└────────────────────────────────────────────────────────────────┘

The data flow in plain English:

  1. A user opens Jellyseerr and requests a movie
  2. Jellyseerr sends the request to Radarr
  3. Radarr searches Prowlarr for available releases
  4. Prowlarr queries all configured indexers and returns results
  5. Radarr picks the best match per your quality profile and sends it to qBittorrent
  6. qBittorrent downloads to /data/torrents/movies/
  7. When complete, Radarr hardlinks (not copies) the file to /data/media/movies/
  8. Jellyfin sees the new file during its library scan and picks it up
  9. Bazarr notices the new file and downloads matching subtitles

All of this happens automatically. A user makes a request; 20-60 minutes later it appears in Jellyfin ready to stream.


Part 3: Directory Structure and Storage Planning

Why the Layout Matters

The single most important architectural decision in this stack is the directory layout. Get it right and file management is instant and storage-efficient. Get it wrong and you waste disk space copying gigabytes.

The key is hardlinks. When Radarr “moves” a downloaded file from the torrents directory to the media directory, it does not actually copy the file — it creates a hardlink. A hardlink is a second directory entry pointing to the same underlying data on disk. The result: the file appears in both locations simultaneously, takes up zero additional space, and can be deleted from either location independently.

This only works if both directories are on the same filesystem. This is why all containers must map the same /data path — if Radarr sees /torrents and /media as separate volumes from separate filesystems, hardlinks fail and it falls back to physically copying files (slow, doubles your disk usage while seeding).

/data/
├── torrents/
│   ├── movies/          ← qBittorrent writes here (category: movies)
│   ├── tv/              ← qBittorrent writes here (category: tv)
│   └── incomplete/      ← in-progress downloads
└── media/
    ├── movies/          ← Radarr hardlinks finished downloads here
    └── tv/              ← Sonarr hardlinks finished downloads here

Create this structure before deploying:

1
2
3
sudo mkdir -p /data/torrents/{movies,tv,incomplete}
sudo mkdir -p /data/media/{movies,tv}
sudo chown -R 1000:1000 /data

Replace 1000:1000 with your actual UID/GID (id -u and id -g).

NAS Storage

If your media lives on a separate NAS device, mount it at /data/media before the containers start:

NFS mount in /etc/fstab:

nas.local:/volume1/media  /data/media  nfs  defaults,nfsvers=4,soft,timeo=30  0  0

SMB mount in /etc/fstab:

//nas.local/media  /data/media  cifs  credentials=/etc/nas-creds,uid=1000,gid=1000,iocharset=utf8  0  0

Keep torrent downloads on a local fast drive (/data/torrents) and media on the NAS. As long as both are on the same logical mount point visible to all containers, hardlinks work — but note that hardlinks across NFS/SMB boundaries do not work. In a NAS setup, you typically want to move (copy + delete) rather than hardlink between local and NAS. Radarr and Sonarr handle this gracefully; just be aware you temporarily double disk usage during the copy.

File Naming

Radarr and Sonarr enforce naming conventions automatically when they import files. You configure the template once and every file gets renamed on import. The defaults are Jellyfin/Plex-compatible, so metadata matching works out of the box.


Part 4: The Complete Docker Compose Setup

Create a project directory and drop in the following files:

1
2
mkdir -p ~/media-stack
cd ~/media-stack

.env file

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# ~/media-stack/.env

# Your user and group ID (run: id -u && id -g)
PUID=1000
PGID=1000

# Your local timezone (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
TZ=America/New_York

# Base path for all media and download data
DATA_PATH=/data

# Config directory for all service configs
CONFIG_PATH=/opt/media-stack

# VPN credentials (for gluetun — update to your provider)
VPN_SERVICE_PROVIDER=mullvad
VPN_TYPE=wireguard
WIREGUARD_PRIVATE_KEY=your_wireguard_private_key_here
WIREGUARD_ADDRESSES=10.x.x.x/32
SERVER_COUNTRIES=USA

docker-compose.yml

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# ~/media-stack/docker-compose.yml
version: "3.9"

networks:
  media:
    driver: bridge

services:

  # ─────────────────────────────────────────────
  # VPN — gluetun provides a VPN tunnel for qBittorrent
  # ─────────────────────────────────────────────
  gluetun:
    image: qmcgaw/gluetun:latest
    container_name: gluetun
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    ports:
      - "8080:8080"    # qBittorrent web UI (exposed through gluetun)
      - "6881:6881"    # BitTorrent port
      - "6881:6881/udp"
    environment:
      - VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
      - VPN_TYPE=${VPN_TYPE}
      - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
      - WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
      - SERVER_COUNTRIES=${SERVER_COUNTRIES}
      - TZ=${TZ}
    volumes:
      - ${CONFIG_PATH}/gluetun:/gluetun
    networks:
      - media
    restart: unless-stopped

  # ─────────────────────────────────────────────
  # qBittorrent — download client, runs inside gluetun's network
  # ─────────────────────────────────────────────
  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    network_mode: "service:gluetun"   # All traffic goes through gluetun VPN
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
      - WEBUI_PORT=8080
    volumes:
      - ${CONFIG_PATH}/qbittorrent:/config
      - ${DATA_PATH}:/data
    depends_on:
      - gluetun
    restart: unless-stopped

  # ─────────────────────────────────────────────
  # Jellyfin — media server and streaming frontend
  # ─────────────────────────────────────────────
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${CONFIG_PATH}/jellyfin:/config
      - ${DATA_PATH}/media:/data/media
    ports:
      - "8096:8096"    # Web UI
      - "8920:8920"    # HTTPS (optional)
      - "7359:7359/udp" # Local network discovery
      - "1900:1900/udp" # DLNA
    devices:
      - /dev/dri:/dev/dri   # Intel QSV / VAAPI hardware transcoding
    group_add:
      - "render"            # Required for /dev/dri on some distros
    networks:
      - media
    restart: unless-stopped

  # ─────────────────────────────────────────────
  # Radarr — movie management and automation
  # ─────────────────────────────────────────────
  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${CONFIG_PATH}/radarr:/config
      - ${DATA_PATH}:/data     # MUST be the full /data so hardlinks work
    ports:
      - "7878:7878"
    networks:
      - media
    restart: unless-stopped

  # ─────────────────────────────────────────────
  # Sonarr — TV show management and automation
  # ─────────────────────────────────────────────
  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${CONFIG_PATH}/sonarr:/config
      - ${DATA_PATH}:/data     # Same /data mount — critical for hardlinks
    ports:
      - "8989:8989"
    networks:
      - media
    restart: unless-stopped

  # ─────────────────────────────────────────────
  # Prowlarr — unified indexer manager
  # ─────────────────────────────────────────────
  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${CONFIG_PATH}/prowlarr:/config
    ports:
      - "9696:9696"
    networks:
      - media
    restart: unless-stopped

  # ─────────────────────────────────────────────
  # Bazarr — automatic subtitle downloading
  # ─────────────────────────────────────────────
  bazarr:
    image: lscr.io/linuxserver/bazarr:latest
    container_name: bazarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${CONFIG_PATH}/bazarr:/config
      - ${DATA_PATH}/media:/data/media
    ports:
      - "6767:6767"
    networks:
      - media
    restart: unless-stopped

  # ─────────────────────────────────────────────
  # Jellyseerr — user-facing request portal
  # ─────────────────────────────────────────────
  jellyseerr:
    image: fallenbagel/jellyseerr:latest
    container_name: jellyseerr
    environment:
      - LOG_LEVEL=debug
      - TZ=${TZ}
      - PORT=5055
    volumes:
      - ${CONFIG_PATH}/jellyseerr:/app/config
    ports:
      - "5055:5055"
    networks:
      - media
    restart: unless-stopped

  # ─────────────────────────────────────────────
  # Flaresolverr — Cloudflare bypass for certain indexers
  # ─────────────────────────────────────────────
  flaresolverr:
    image: ghcr.io/flaresolverr/flaresolverr:latest
    container_name: flaresolverr
    environment:
      - LOG_LEVEL=info
      - LOG_HTML=false
      - CAPTCHA_SOLVER=none
      - TZ=${TZ}
    ports:
      - "8191:8191"
    networks:
      - media
    restart: unless-stopped

Create config directories and deploy:

1
2
3
sudo mkdir -p /opt/media-stack/{jellyfin,radarr,sonarr,prowlarr,bazarr,jellyseerr,gluetun,qbittorrent}
sudo chown -R 1000:1000 /opt/media-stack
docker compose up -d

NVIDIA GPU Transcoding

If you have an NVIDIA GPU instead of Intel, replace the jellyfin devices section with:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    # ... same environment and volumes ...
    runtime: nvidia
    environment:
      - NVIDIA_VISIBLE_DEVICES=all
      - NVIDIA_DRIVER_CAPABILITIES=all
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

This requires nvidia-container-toolkit installed on the host (apt install nvidia-container-toolkit).


Part 5: Configuring qBittorrent

First Login

qBittorrent’s web UI is accessible at http://server-ip:8080. The default credentials are:

  • Username: admin
  • Password: adminadmin

Change the password immediately in Settings → Web UI → Authentication.

Download Path Configuration

In Settings → Downloads:

  • Default Save Path: /data/torrents/incomplete
  • Keep incomplete torrents in: /data/torrents/incomplete
  • Move completed downloads to: leave unchecked (Radarr/Sonarr manage placement via categories)

Category Setup

Radarr and Sonarr use qBittorrent categories to track their downloads. Create two categories:

In qBittorrent, right-click the left sidebar → Add Category:

Category Save Path
movies /data/torrents/movies
tv /data/torrents/tv

When Radarr sends a download to qBittorrent, it automatically assigns the movies category, which routes the finished file to /data/torrents/movies. Radarr then picks it up from there and hardlinks it to /data/media/movies.

Speed Limits and Scheduling

In Settings → Speed:

  • Set upload limits during peak hours to avoid saturating your connection
  • Use the Alternative Speed Limits and schedule them under Settings → BitTorrent → Schedule to apply slower speeds during the day

VPN Kill Switch with gluetun

The Docker Compose above runs qBittorrent with network_mode: service:gluetun, which routes all qBittorrent traffic through the gluetun container’s VPN tunnel. If the VPN drops, gluetun kills the network interface — the kill switch. qBittorrent loses connectivity entirely rather than leaking your real IP.

To configure gluetun for different providers, adjust the environment variables:

Mullvad (WireGuard):

1
2
3
4
- VPN_SERVICE_PROVIDER=mullvad
- VPN_TYPE=wireguard
- WIREGUARD_PRIVATE_KEY=<from mullvad account page>
- WIREGUARD_ADDRESSES=10.x.x.x/32

ProtonVPN (OpenVPN):

1
2
3
4
5
- VPN_SERVICE_PROVIDER=protonvpn
- VPN_TYPE=openvpn
- OPENVPN_USER=<protonvpn username>
- OPENVPN_PASSWORD=<protonvpn password>
- SERVER_COUNTRIES=Switzerland

Private Internet Access:

1
2
3
4
- VPN_SERVICE_PROVIDER=private internet access
- VPN_TYPE=openvpn
- OPENVPN_USER=<pia username>
- OPENVPN_PASSWORD=<pia password>

Verify the VPN is working before proceeding: docker exec gluetun curl ifconfig.io should return a VPN IP, not your home IP.


Part 6: Configuring Prowlarr

What Prowlarr Does

Prowlarr is a single control plane for all your torrent and Usenet indexers. Instead of configuring each indexer separately in Radarr and Sonarr, you add them once in Prowlarr and it syncs them to all your *arr apps automatically. It also handles searching and returns standardized results regardless of whether the indexer is a torrent tracker or a Usenet provider.

Access Prowlarr at http://server-ip:9696.

Adding Indexers

Navigate to Indexers → Add Indexer. Prowlarr includes built-in support for hundreds of public and private trackers. Some useful public ones:

  • 1337x — large general tracker
  • YTS (YIFY) — high-quality movie encodes at smaller file sizes
  • EZTV — TV shows
  • Nyaa — anime

For private trackers, you’ll need your credentials from the respective tracker.

After adding an indexer, click Test to confirm connectivity.

Connecting Prowlarr to Radarr and Sonarr

Navigate to Settings → Apps → Add Application:

  1. Add Radarr:

    • Server: http://radarr:7878
    • API Key: (find in Radarr → Settings → General → API Key)
    • Sync Level: Full Sync
  2. Add Sonarr:

    • Server: http://sonarr:8989
    • API Key: (find in Sonarr → Settings → General → API Key)
    • Sync Level: Full Sync

Once configured, any indexer you add to Prowlarr automatically appears in both Radarr and Sonarr. No manual indexer configuration needed in either app.

Flaresolverr Integration

Some indexers are protected by Cloudflare’s bot detection. Flaresolverr solves these challenges by running a real browser session in the background.

In Prowlarr → Settings → Indexers → Add Proxy:

  • Type: FlareSolverr
  • URL: http://flaresolverr:8191

Then, when adding a Cloudflare-protected indexer, select the FlareSolverr proxy in the indexer settings.


Part 7: Configuring Radarr

Access Radarr at http://server-ip:7878.

Root Folder

Settings → Media Management → Root Folders → Add Root Folder:

  • Path: /data/media/movies

Adding qBittorrent as a Download Client

Settings → Download Clients → Add:

  • Client: qBittorrent
  • Host: gluetun (the container name — qBittorrent’s network lives in gluetun)
  • Port: 8080
  • Username: admin
  • Password: (your new password)
  • Category: movies

The category is critical — it ensures qBittorrent routes the download to /data/torrents/movies.

Quality Profiles

Settings → Quality Profiles. The default HD-1080p profile works well for most setups. Customize if you want:

Quality Profile: "1080p Preferred"
Upgrades allowed: Yes
Upgrade Until: Bluray-1080p

Quality order (best first):
  1. Bluray-1080p (Remux)
  2. Bluray-1080p
  3. WEB-DL 1080p
  4. WEBRip 1080p
  5. HDTV-1080p
  --- cutoff above ---
  6. WEB-DL 720p
  (lower qualities disabled)

Naming Format

Settings → Media Management → Movie Naming:

Standard Movie Format:
{Movie Title} ({Release Year}) {Quality Full}

Movie Folder Format:
{Movie Title} ({Release Year})

Example output: The Dark Knight (2008) Bluray-1080p

This format is fully compatible with Jellyfin and Plex metadata matching.

Custom Formats

Custom formats let you express preferences beyond quality resolution. In Settings → Custom Formats:

Create a Prefer x265 format:

Name: Prefer x265
Conditions:
  - Release Title: x265|HEVC|H\.265  (regex)
Score: +10

Create an Avoid CAM format:

Name: Avoid CAM
Conditions:
  - Release Title: CAM|TS\.|TELESYNC  (regex)
Score: -10000  (hard block)

Radarr Lists

Settings → Import Lists lets you auto-populate your movie library from external lists. Options include:

  • IMDb Watchlist (your personal list)
  • Trakt.tv lists (popular lists, themed collections)
  • StevenLu’s Popular Movies
  • Radarr’s own discovery feeds

With a list configured, Radarr monitors it and automatically adds (and optionally searches for) new entries.

Adding a Movie Manually

Movies → Add New → search → select result → pick quality profile → Add Movie. Radarr searches all configured indexers, finds a matching release, and queues it in qBittorrent. The whole process takes seconds.


Part 8: Configuring Sonarr

Sonarr at http://server-ip:8989 is functionally identical to Radarr but for TV shows. The same root folder, download client, and quality profile setup applies.

Root Folder

Settings → Media Management → Root Folders:

  • Path: /data/media/tv

Download Client

Same as Radarr but set Category to tv.

Series Types

When adding a series, Sonarr asks for a type:

  • Standard: Regular scripted TV with S01E01 naming (the default)
  • Daily: News, talk shows, late night — episodes identified by air date
  • Anime: Absolute episode numbering common in anime releases

Season Monitoring Options

When adding a show, you choose what to monitor:

  • All Seasons: Grab everything, past and present
  • Future Seasons: Only monitor upcoming seasons (useful for ongoing shows you just want to follow going forward)
  • Latest Season: Just the most recent season
  • Missing Episodes: Only grab episodes you’re missing from seasons you’ve watched

Rename Format for TV

Settings → Media Management → Episode Naming:

Standard Episode Format:
{Series Title} - S{season:00}E{episode:00} - {Episode Title}

Daily Episode Format:
{Series Title} - {Air-Date} - {Episode Title}

Series Folder Format:
{Series Title}

Season Folder Format:
Season {season:00}

This produces a clean hierarchy:

/data/media/tv/
└── Breaking Bad/
    ├── Season 01/
    │   ├── Breaking Bad - S01E01 - Pilot.mkv
    │   └── Breaking Bad - S01E02 - Cat's in the Bag.mkv
    └── Season 02/
        └── ...

Jellyfin and Plex both parse this format perfectly for metadata matching.


Part 9: Configuring Jellyfin

Jellyfin is available at http://server-ip:8096.

Initial Setup Wizard

On first launch, Jellyfin walks you through:

  1. Create admin account (use a strong password)
  2. Add media libraries (see below)
  3. Set preferred language for metadata

Library Setup

Dashboard → Libraries → Add Media Library:

Movies:

  • Content type: Movies
  • Folders: /data/media/movies
  • Metadata downloader: TheMovieDB (enable, move to top)
  • Image fetchers: TheMovieDB, FanArt (optional)

TV Shows:

  • Content type: Shows
  • Folders: /data/media/tv
  • Metadata downloader: TheTVDB, TheMovieDB
  • Image fetchers: TheTVDB, FanArt

Trigger a full library scan after setup: Dashboard → Libraries → (library name) → Scan All Libraries.

Hardware Transcoding

Hardware transcoding lets the server offload video encoding from the CPU to dedicated silicon, supporting many more simultaneous streams while using far less power.

Dashboard → Playback → Transcoding:

Intel Quick Sync (VAAPI) — covered by the /dev/dri device passthrough in Compose:

  • Hardware acceleration: Video Acceleration API (VAAPI)
  • VAAPI device: /dev/dri/renderD128
  • Enable hardware decoding for H.264, HEVC, VP9, AV1 (check all supported)
  • Enable hardware encoding: yes

NVIDIA (with nvidia-container-toolkit Compose setup):

  • Hardware acceleration: NVENC
  • Enable hardware decoding: yes for all supported codecs

Verify transcoding is working by starting playback and checking the session in Dashboard → Dashboard (home). An active transcode shows the stream codec and whether it’s hardware-assisted.

Direct Play vs Transcode: When the client can play the file natively (same codec, right bitrate, matching audio), Jellyfin serves the file directly with zero server CPU overhead. Transcoding only happens when the client needs a different format or lower bitrate. Encourage direct play by using common codecs (H.264 MKV or MP4) and ensuring clients have capable hardware decoders.

User Management

Dashboard → Users → Add User. Create accounts for each family member. Per user you can:

  • Set a PIN or password
  • Restrict to specific libraries
  • Set content rating limits (G, PG, PG-13, etc.)
  • Enable/disable downloads
  • Control playback permissions

Clients

Jellyfin has official clients for nearly every platform:

Platform Client
Web browser Jellyfin Web (built-in)
Android Jellyfin for Android (Play Store)
iOS / iPadOS Jellyfin for iOS (App Store)
Apple TV Infuse or Jellyfin for Apple TV
Roku Jellyfin for Roku
Amazon Fire TV Jellyfin for Fire TV
Android TV / Google TV Jellyfin for Android TV
Kodi Jellyfin for Kodi plugin
Desktop Jellyfin Media Player

Useful Plugins

Dashboard → Plugins → Catalog:

  • Intro Skipper: Automatically detects and marks intro sequences; adds “Skip Intro” button à la Netflix
  • Open Subtitles: Direct integration with OpenSubtitles.com for on-demand subtitle fetching within Jellyfin
  • Playback Reporting: Usage analytics, watch time statistics
  • Merge Versions: Deduplicate movies where you have multiple quality files

Part 10: Bazarr — Automatic Subtitles

Bazarr at http://server-ip:6767 monitors your media library and automatically downloads subtitles for every file.

Connecting to Radarr and Sonarr

Settings → Radarr:

  • Address: radarr
  • Port: 7878
  • API Key: (from Radarr → Settings → General)
  • Base URL: (leave empty)

Settings → Sonarr: same pattern with port 8989.

Once connected, Bazarr imports your full library from both services.

Subtitle Providers

Settings → Providers → Add Provider:

  • OpenSubtitles.com (recommended): Create a free account at opensubtitles.com. Very large library, good language coverage.
  • Subscene: Large library, no account required
  • Podnapisi: Good for non-English content

Language Profiles

Settings → Languages → Add Profile:

Profile: "English + Spanish"
Languages:
  - English (SDH preferred for hearing impaired)
  - Spanish

Assign this profile to all movies and shows via Settings → Radarr → Default Settings.

Automatic Download Behavior

In Settings → Subtitles:

  • Upgrade previously downloaded subtitles: Yes (replaces poor quality subs when better ones appear)
  • Download hearing-impaired subtitles: depends on preference
  • Score threshold: Only download if match score > 80 (avoids poor machine-translated subs)

Part 11: Jellyseerr — The Netflix-Like Request Interface

Jellyseerr at http://server-ip:5055 is the public face of your media server. It is where users go to request content — without needing access to Radarr, Sonarr, or any of the backend tooling.

Initial Setup

On first launch, Jellyseerr prompts you to:

  1. Sign in with Jellyfin: Enter your Jellyfin URL (http://jellyfin:8096), admin credentials. Jellyseerr then uses Jellyfin for all user authentication — your family logs into Jellyseerr with their Jellyfin usernames.

  2. Configure Radarr:

    • Default Server: Yes
    • Server Name: Main Radarr
    • Hostname: radarr
    • Port: 7878
    • API Key: (from Radarr)
    • Quality Profile: 1080p Preferred
    • Root Folder: /data/media/movies
    • Enable: Yes
  3. Configure Sonarr: Same pattern with sonarr:8989.

The Request Workflow

From a user’s perspective, Jellyseerr feels like a streaming service discovery interface:

  1. Search for a movie or show
  2. Click Request
  3. Done — Jellyseerr sends the request to Radarr or Sonarr in the background

Within minutes to an hour (depending on indexer speed and file size), the content appears in Jellyfin. Users get a notification when it’s ready.

Approval Workflows

Settings → General:

  • Auto-Approve Requests: If enabled, all requests go straight to Radarr/Sonarr without admin review. Good for trusted family setups.
  • Manual Approval: Requests queue for admin review. Good if you want to gatekeep or manage storage.

Per-user overrides are possible: Settings → Users → (user) → enable auto-approve for specific users.

Notifications

Settings → Notifications. Jellyseerr can notify you (and users) through:

  • Discord (webhook): Post to a family Discord server when requests are approved and when media is available
  • Email: Native SMTP support
  • Slack: Webhook integration
  • Telegram, Pushover, Ntfy: For push notifications to phones

Configure a Discord webhook to keep everyone informed without them polling the UI.


Part 12: Exposing Jellyfin Externally

Your internal services need to stay internal. Radarr, Sonarr, Prowlarr, qBittorrent, Bazarr — none of these should ever be accessible from the internet. They have no meaningful authentication and contain your full download history.

Jellyfin, however, is designed to be accessed remotely. There are three good approaches:

Option 1: Traefik Reverse Proxy with Let’s Encrypt

If you’re already running Traefik (see the Traefik Complete Guide), adding Jellyfin is just labels:

1
2
3
4
5
6
7
8
  jellyfin:
    # ... existing config ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.jellyfin.rule=Host(`jellyfin.yourdomain.com`)"
      - "traefik.http.routers.jellyfin.entrypoints=websecure"
      - "traefik.http.routers.jellyfin.tls.certresolver=letsencrypt"
      - "traefik.http.services.jellyfin.loadbalancer.server.port=8096"

This gives you https://jellyfin.yourdomain.com with an auto-renewing Let’s Encrypt certificate. Requires port 443 forwarded to your server.

Option 2: Cloudflare Tunnel

If you can’t or don’t want to open ports on your router, Cloudflare Tunnel creates an outbound-only encrypted connection from your server to Cloudflare’s edge:

1
2
3
# Install cloudflared
docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run \
  --token YOUR_TUNNEL_TOKEN

Or as a Compose service:

1
2
3
4
5
6
7
8
9
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    command: tunnel --no-autoupdate run
    environment:
      - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
    networks:
      - media
    restart: unless-stopped

Configure the tunnel in the Cloudflare Zero Trust dashboard to route jellyfin.yourdomain.com to http://jellyfin:8096. Zero port forwarding required.

Option 3: Tailscale

For a family setup where everyone is technically proficient enough to install Tailscale on their devices, this is the cleanest option. Install Tailscale on your server and all client devices:

1
2
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up

Family members install the Tailscale app on their phone or TV and connect to the same tailnet. They access Jellyfin via your server’s Tailscale IP — fully encrypted, no public exposure, no port forwarding. The Jellyfin app on Apple TV, Roku, and Android TV works seamlessly over Tailscale.

Security Reminder

Regardless of the external access method, apply these rules:

  • Use a strong admin password on Jellyfin
  • Enable two-factor authentication if your external proxy supports it
  • Never expose Radarr, Sonarr, Prowlarr, Bazarr, Jellyseerr, or qBittorrent directly to the internet
  • If using Traefik, put a middleware like ipAllowList on all internal services to ensure they’re only accessible locally

Part 13: Maintenance and Long-Term Operations

Library Scans

Jellyfin runs a scheduled library scan by default (every 24 hours). For a more responsive experience — new content appears minutes after download — enable real-time monitoring:

Dashboard → Libraries → (library) → Display: Enable real-time monitoring.

Jellyfin watches the filesystem with inotify and picks up new files immediately. On large NAS-backed libraries over NFS, real-time monitoring can be unreliable; scheduled scans are more stable in that case.

Health Checks

Radarr and Sonarr both have a Health section (System → Status → Health). Check it weekly. Common issues it surfaces:

  • Indexers returning no results (the tracker may be down or changed its URL)
  • Download client unreachable (VPN dropped, container restarted)
  • Root folder not writable (permissions issue)
  • Disk nearly full

Storage Monitoring

Add a Prometheus + Grafana alert (see the Prometheus + Grafana Stack guide) to notify when /data exceeds 80% capacity:

1
2
3
4
5
6
7
8
9
# prometheus alert rule
- alert: MediaDriveFull
  expr: (node_filesystem_avail_bytes{mountpoint="/data"} / node_filesystem_size_bytes{mountpoint="/data"}) * 100 < 20
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "Media drive over 80% full"
    description: "{{ $value }}% free remaining on /data"

A simpler option: Uptime Kuma has a disk space monitor built in that can send Telegram/Slack/Discord notifications.

Updating Containers

Keep all images current without manual work by tracking your update schedule. Pull and recreate:

1
2
3
cd ~/media-stack
docker compose pull
docker compose up -d

Or use Watchtower for fully automatic updates:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  watchtower:
    image: containrrr/watchtower:latest
    container_name: watchtower
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_SCHEDULE=0 0 4 * * *   # 4 AM daily
      - WATCHTOWER_NOTIFICATIONS=slack
      - WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL=${SLACK_WEBHOOK}
    restart: unless-stopped

Watchtower with WATCHTOWER_CLEANUP=true automatically removes old image layers after updating, keeping disk usage in check.

Seeding and Reclaiming Space

After a torrent completes and Radarr/Sonarr hardlink it to the media directory, the torrent continues seeding from /data/torrents/. You want to seed for a reasonable ratio (1.0 is the community norm for public trackers) before removing the torrent file.

In Radarr/Sonarr → Settings → Download Clients → (qBittorrent entry):

  • Remove Completed: Enable
  • Removal Delay: 0 minutes (or set a delay to ensure adequate seeding time)

For ratio-based cleanup, configure it directly in qBittorrent: Settings → BitTorrent → Seeding Limits → set ratio to 1.0 and pause or remove torrent when met.

4K Setup

Running a 4K library alongside your 1080p library requires separate *arr instances, as each can only target one quality tier per instance cleanly.

The standard setup:

  1. Radarr4K — separate container on port 7879, root folder /data/media/movies4k
  2. Sonarr4K — separate container on port 8990, root folder /data/media/tv4k
  3. Jellyfin — add /data/media/movies4k as a separate Movies library or subfolder

In Radarr4K’s quality profile, set the cutoff to Bluray-2160p and enable HDR custom format preferences. For hardware transcoding, 4K HDR tone-mapping to SDR for clients that don’t support HDR requires either:

  • A GPU with HDR tone-mapping support (NVIDIA with recent drivers, or Intel Gen 12+)
  • Or accepting that non-4K clients need significant CPU resources for tone-mapped transcodes

A practical rule: if more than half your viewers are on 4K HDR TVs, maintain a 4K library. Otherwise, a high-quality 1080p encode is often the better tradeoff for streaming flexibility.


Putting It All Together

The beauty of this stack is that once it is configured — a few hours of work — it runs itself indefinitely. Add a new show to Sonarr and every episode, past and future, is managed for you. A family member opens Jellyseerr, requests something they saw advertised, and finds it available in Jellyfin before the week is out. Subtitles appear automatically. Jellyfin serves the right format to every device without you thinking about it.

The full startup checklist:

[ ] /data directory structure created, permissions set
[ ] docker-compose.yml and .env deployed, all containers healthy
[ ] gluetun VPN verified: docker exec gluetun curl ifconfig.io
[ ] qBittorrent: default password changed, categories created
[ ] Prowlarr: indexers added and tested, Radarr + Sonarr connected
[ ] Radarr: root folder set, download client configured, quality profile set
[ ] Sonarr: root folder set, download client configured, naming format set
[ ] Jellyfin: admin account created, libraries scanned, hardware transcode enabled
[ ] Bazarr: providers configured, language profile set, Radarr/Sonarr connected
[ ] Jellyseerr: Jellyfin auth configured, Radarr + Sonarr connected
[ ] External access: Traefik / Cloudflare Tunnel / Tailscale configured for Jellyfin only
[ ] Storage monitoring alert configured

The first time you see a request come in through Jellyseerr, watch the download start in qBittorrent, see Radarr import the file, and then find it ready to stream in Jellyfin — all without touching anything — it clicks. This is what the automation was for.

Comments