Back to Blog
homelab
14 min read

Jellyfin: Avoiding subscriptions like the plague

How I set up an arr stack (Jellyfin, Radarr, Sonarr, Prowlarr and qBittorrent) with Docker Compose on a Raspberry Pi 5 8GB and a 1TB external hard drive.

Tang Yetong
Tang Yetong
Full Stack Developer and former Data Analyst Intern at Fivetran with a background in government digital services and blockchain technology.

A few weeks ago, I got tired of constantly asking my girlfriend to bring her iPad over to my house just to watch shows and movies. I have a TV at home, and constantly using the iPad never felt right.

I've never had a Netflix subscription, because I never knew if I could make it worth it. Would I really watch at least $20 of shows every month?

Then I stumbled onto some YouTube Shorts about setting up an *arr stack (Radarr, Sonarr, Jellyfin and friends), and it piqued my interest. It didn't look difficult or complicated, and it sounded like a fun weekend project.

This post is how I set it up, in the order that worked for me, with bits you can click on as you read.

What I'm actually building

If you haven't met the term: an arr stack is a set of self-hosted apps, most of them named Something-arr, that find, download and organise movies and TV shows for a media server. Radarr handles movies, Sonarr handles TV, Prowlarr manages the indexers, and Jellyfin plays the result.

The setup itself looked pretty straightforward. One Raspberry Pi runs all of the software, and the movies and series sit on an external hard drive. I'd say it's straightforward even with nine pieces that fit together, because each one has a pretty simple purpose:

JellyfinThe media player, my "Netflix" screen
SeerrThe request website, my "menu"
qBittorrentDownloads the files
RadarrSearches for and manages movies
SonarrSearches for and manages TV shows
ProwlarrManages the indexers and feeds Sonarr and Radarr
BazarrDownloads subtitles
FlareSolverrGets Prowlarr past anti-bot checks
MaintainerrAuto-deletes old media to free up space

Here's how they all connect together. Tap an app to see who it talks to, or press Play to follow one movie from request to TV.

Try it · stack mapOverview
ASKFINDEXTRASGET & WATCHTOUCHES THE DRIVEOPTIONAL EXTRASeerr:5055Radarr:7878Prowlarr:9696Sonarr:8989Bazarr:6767FlareSolverr:8191Maintainerr:6246qBittorrent:8080Jellyfin:80961TB external drive/mnt/mediamovie requestshow requestindexersindexersbeats anti-botdownloaddownloadsaves filesplaysmoviesshowshistorydeletesdeletes
Overview
Nine apps, one drive, one Pi

Each box is a Docker container on the Pi, and the arrows show the main path a request takes. Dashed boxes are optional extras, and a green square means the app reads or writes the shared drive.

Tap a box to see all of its connections, or press Play to follow a single movie from request to TV.

9 containers1 network1 drive

Why isn't this just one plug-and-play app?

Nine containers is a lot of moving parts for "watch a movie". It makes sense when you think about it: each app does exactly one job, and they talk to each other through plain web addresses and API keys.

The upside is the flexibility to swap, update or switch off any single piece without touching the rest. For a stack like this, being able to swap out an unmaintained app for an alternative matters, since they are all community-maintained. If you really wanted to, you could even write your own app and hook it into the stack. I'd say this is an essential part of what makes the *arr stack work as a whole.

Diagram · what could take its place9 slots
The menuSeerrJellyseerrOverseerr
The playerJellyfinPlexEmby
DownloaderqBittorrentDelugeTransmission
MoviesRadarrNo easy swap: Seerr, Bazarr, Prowlarr and Maintainerr expect it
TV showsSonarrNo easy swap: Seerr, Bazarr, Prowlarr and Maintainerr expect it
IndexersProwlarrJackett
SubtitlesBazarrJellyfin OpenSubtitles pluginor nothing, it is optional
Anti-bot helperFlareSolverrByparror nothing, it is optional
The janitorMaintainerrJanitorror nothing, it is optional
What I runWhat could replace itOptional, or hard to swap

You will feel the downside while setting everything up, though: more things to set up and keep updated. Debugging can also be a hassle.

Picking the Pi, and everything else I needed

A bit of digging around landed me on the Raspberry Pi 5 8GB. The price gap between the Pi 4 and the Pi 5 isn't big, so I went with the newer one, and the 8GB gives me some leeway for smaller side projects later.

Raspberry Pi 4 · 8GB
US price
$165
CPU
4× Cortex-A72, 1.8 GHz
Power
15W USB-C
Raspberry Pi 5 · 8GB · my pick
US price
$175
CPU
4× Cortex-A76, 2.4 GHz
Power
27W USB-C
  • The gap: about $10 in the US when I checked on 29 Oct 2026.
  • Prices keep moving. The Pi 5 8GB launched at $80 in 2023. Raspberry Pi has raised prices several times since because of memory costs, most recently on 1 April 2026.

With the Pi picked, this is what I actually bought.

Shopping listprices in S$
Raspberry Pi 5 (8GB)

The brain. It runs all nine containers.

Where
element14 SG
S$236.32
Official case

Keeps dust and fingers off the board.

Where
element14 SG
S$13.51
Official 27W USB-C power supply

The Pi 5 wants 5V/5A so the USB drive gets full power.

Where
element14 SG
S$16.21
GST (9%) on the order above

The three prices above are before GST.

Where
element14 SG
S$23.94
32GB microSD card

Only holds the OS and each app's settings. The heavy writing goes to the drive.

Where
Shopee
S$19.94
USB-C microSD card reader

Needed to write the OS onto the microSD card from a laptop.

Where
Shopee
S$4.39
1TB external hard drive

The library. Movies and shows live here.

Where
A spare one I had lying around
S$0
TotalS$314.31

The Pi, case and power supply came to S$289.98 with GST. The microSD card and its reader make up the rest, and the drive was a spare I already had.

It took about a week for everything to arrive, since some of the parts came from London.

Setting it up, step by step

This was the flow I followed when setting everything up. The core components are quite linear, and the optional apps come in any time.

Try it · setup flow9 core steps · 4 optional
Tap a step to open it.
Prepare the Pi

I flash Raspberry Pi OS Lite (64-bit) with Raspberry Pi Imager. Fill in the OS settings before writing the card: hostname mediapi, your user and password, SSH on, timezone Asia/Singapore. Plug in Ethernet, boot, then reserve 192.168.0.22 for the Pi in the router so the address never changes. Every URL later on depends on it.

ssh youruser@mediapi.local
sudo apt update && sudo apt full-upgrade -y
sudo reboot
ssh connects and your router lists the Pi at 192.168.0.22.

One script installs Docker and the Compose plugin. Log out and back in so the group change applies.

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
# log out, log back in, then:
docker compose version
docker run --rm hello-world
hello-world prints its greeting.
Formatting erases the drive. Check the device name with lsblk first. /dev/sda1 below is only an example.

Use ext4: NTFS and exFAT break hardlinks and Linux permissions. The nofail option stops the Pi hanging at boot if the drive is unplugged.

lsblk
sudo mkfs.ext4 -L media /dev/sda1
sudo mkdir -p /mnt/media
sudo blkid /dev/sda1
echo 'UUID=PASTE-UUID-HERE /mnt/media ext4 defaults,nofail 0 2' | sudo tee -a /etc/fstab
sudo mount -a
df -h /mnt/media
df shows the drive mounted at /mnt/media.

Downloads and the library sit side by side under one root so they share a filesystem. The hardlink lab below shows why that matters.

sudo mkdir -p /mnt/media/data/downloads /mnt/media/data/media/tv /mnt/media/data/media/movies
sudo chown -R 1000:1000 /mnt/media/data
mkdir -p ~/stack/config
ls /mnt/media/data lists downloads and media.
Launch

Build your file in the compose builder, save it as ~/stack/docker-compose.yml, then start everything.

cd ~/stack
docker compose up -d
docker compose ps
compose ps lists your containers as running.
Connect the core apps

Log in with the temporary password from the container log, set your own, and set the default save path to /data/downloads. Add a seeding limit whose action is to remove the torrent and its files, so space comes back.

docker logs qbittorrent 2>&1 | grep -i password
The save path is /data/downloads and a seeding limit is set.

Add a root folder (/data/media/movies in Radarr, /data/media/tv in Sonarr). Keep "Use Hardlinks instead of Copy" on. Add qBittorrent as the download client with the category radarr or sonarr, and copy each app's API key from Settings, General.

Test passes on the qBittorrent download client in both.

Add your indexers, then add Radarr and Sonarr under Settings, Apps, and sync. Use each app's API key, and the container name as the host.

Your indexers appear inside Radarr and Sonarr.

Run the wizard and add two libraries: Movies at /data/media/movies and Shows at /data/media/tv. Then create an API key under Dashboard, API Keys for the helper apps.

Both libraries have scanned.
Optional · add any time after launchIn any order. Each one lists what it needs first.
NeedsJellyfinRadarrSonarr

Sign in with Jellyfin, pick the libraries, and add Radarr and Sonarr as destinations with their root folders.

A test request travels all the way to Jellyfin.
NeedsRadarrSonarr

Link Sonarr and Radarr with their API keys, add a subtitle provider, and set a default language profile that outputs SRT.

A test search returns a subtitle.
NeedsProwlarr

Only needed if an indexer sits behind a Cloudflare check page. In Prowlarr, add FlareSolverr as an indexer proxy (http://flaresolverr:8191/) with the tag flaresolverr, then put that tag on the indexers that need it.

A test on a Cloudflare-protected indexer passes.
NeedsJellyfinRadarrSonarr
It deletes for real, with no undo. Run every rule in dry-run first.

Connect Jellyfin, Radarr, Sonarr and Seerr, then build a gentle rule, such as "added more than 180 days ago and not watched in 90 days". Turn on the grace period so titles sit in a Jellyfin collection first.

The dry-run list contains only titles you expected.

My compose file, with your pick of containers

This is my stack written as a docker-compose file. Switch containers on and off and the file changes with it. The checker complains if something depends on an app you left out.

Try it · docker-compose.yml~/stack
cd ~/stack
mkdir -p config/{jellyfin,seerr,qbittorrent,radarr,sonarr,prowlarr,bazarr,maintainerr}
services:
  jellyfin:
    image: lscr.io/linuxserver/jellyfin:latest
    container_name: jellyfin
    environment: [PUID=1000, PGID=1000, TZ=Asia/Singapore]
    volumes:
      - ./config/jellyfin:/config
      - /mnt/media/data/media:/data/media
    ports: ["8096:8096"]
    restart: unless-stopped

  seerr:
    image: ghcr.io/seerr-team/seerr:latest
    container_name: seerr
    init: true
    environment: [TZ=Asia/Singapore, PORT=5055, LOG_LEVEL=info]
    volumes:
      - ./config/seerr:/app/config
    ports: ["5055:5055"]
    restart: unless-stopped

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    environment: [PUID=1000, PGID=1000, TZ=Asia/Singapore, WEBUI_PORT=8080]
    volumes:
      - ./config/qbittorrent:/config
      - /mnt/media/data:/data
    ports: ["8080:8080", "6881:6881", "6881:6881/udp"]
    restart: unless-stopped

  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    environment: [PUID=1000, PGID=1000, TZ=Asia/Singapore]
    volumes:
      - ./config/radarr:/config
      - /mnt/media/data:/data
    ports: ["7878:7878"]
    restart: unless-stopped

  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    environment: [PUID=1000, PGID=1000, TZ=Asia/Singapore]
    volumes:
      - ./config/sonarr:/config
      - /mnt/media/data:/data
    ports: ["8989:8989"]
    restart: unless-stopped

  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    environment: [PUID=1000, PGID=1000, TZ=Asia/Singapore]
    volumes:
      - ./config/prowlarr:/config
    ports: ["9696:9696"]
    restart: unless-stopped

  bazarr:
    image: lscr.io/linuxserver/bazarr:latest
    container_name: bazarr
    environment: [PUID=1000, PGID=1000, TZ=Asia/Singapore]
    volumes:
      - ./config/bazarr:/config
      - /mnt/media/data/media:/data/media
    ports: ["6767:6767"]
    restart: unless-stopped

  flaresolverr:
    image: ghcr.io/flaresolverr/flaresolverr:latest
    container_name: flaresolverr
    environment: [TZ=Asia/Singapore, LOG_LEVEL=info]
    ports: ["8191:8191"]
    restart: unless-stopped

  maintainerr:
    image: ghcr.io/maintainerr/maintainerr:latest
    container_name: maintainerr
    user: "1000:1000"
    environment: [TZ=Asia/Singapore]
    volumes:
      - ./config/maintainerr:/opt/data
    ports: ["6246:6246"]
    restart: unless-stopped
Every container you picked has what it depends on.
Check Maintainerr's volume path (/opt/data) against its current documentation before you rely on it.

Radarr and Sonarr don't copy a finished download into the library, they hardlink it: one file on disk, two names. That only works if both names live on the same mount. Try it with each setting and watch the disk.

Try it · one download, four momentsmoment 1 of 4
DOWNLOADS FOLDERLIBRARY FOLDERONE MOUNT · /DATAMOUNT AMOUNT B/data/downloads/movie.mkv/data/media/movies/movie.mkvmovie.mkv4 GB · 1 namemovie.mkv (copy)4 GB · 1 name
Disk used by this movie
4 GB usedscale 0 to 10 GB

qBittorrent finishes the download. The file sits in downloads and nothing else has happened yet.

Getting the apps to talk to each other

Every connection screen asks for a host. Pick what goes in the host box and press Test. The containers all share one Docker network, so the most reliable way is to use the container name. I reserved 192.168.0.22 as the Pi's IP address, so I can swap the container name for 192.168.0.22 without any real problems as well.

Try it · the host boxRadarr → qBittorrent
Host
qbittorrent
Port
8080
Not tested yet.
Names and ports to typeTap one to copy it.
Jellyfin
Seerr
qBittorrent
Radarr
Sonarr
Prowlarr
Bazarr
FlareSolverr
Maintainerr

Each connection also asks for the other app's API key, found under Settings, General. Some screens want a full address such as http://radarr:7878, others take the host and port separately.

Things that usually go wrong

Nearly every problem I had comes down to a wrong address, a wrong path or a missing permission.

Try it · troubleshooter9 common snags
Likely cause

You entered localhost as the host. Inside a container, that is the container itself.

Fix

Use the container name, such as http://qbittorrent:8080, or the Pi's reserved IP.

Day to day

Day to day it's just: open Seerr, request something, wait. When it shows up in Jellyfin it already has subtitles, unless Bazarr couldn't find any. The microSD card only holds the OS and app settings, and all the heavy writing goes to the 1TB drive. Back up ~/stack/config every now and then, and remember the drive isn't a backup.

cd ~/stack && docker compose pull && docker compose up -d
docker compose ps
docker logs --tail 50 sonarr
df -h /mnt/media

Now, all of the TVs have the Jellyfin TV app installed, and can stream pretty smoothly most of the time. There are some slight issues like transcoding, since the Pi can't transcode on its own, but those are outside the scope of this blog.

Pretty good convenience overall, and I think it's pretty worth it 👍

Tags

Arr Stack
Raspberry Pi
Docker
Jellyfin
Sonarr
Radarr
Self-hosted
Homelab
Tang Yetong

Tang Yetong

Full Stack Developer and former Data Analyst Intern at Fivetran with a background in government digital services and blockchain technology.