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:
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.
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.
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.
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.
- US price
- $165
- CPU
- 4× Cortex-A72, 1.8 GHz
- Power
- 15W USB-C
- 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.
The brain. It runs all nine containers.
element14 SG
Keeps dust and fingers off the board.
element14 SG
The Pi 5 wants 5V/5A so the USB drive gets full power.
element14 SG
The three prices above are before GST.
element14 SG
Only holds the OS and each app's settings. The heavy writing goes to the drive.
Shopee
Needed to write the OS onto the microSD card from a laptop.
Shopee
The library. Movies and shows live here.
A spare one I had lying around
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.
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
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
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
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
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
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
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.
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.
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.
Sign in with Jellyfin, pick the libraries, and add Radarr and Sonarr as destinations with their root folders.
Link Sonarr and Radarr with their API keys, add a subtitle provider, and set a default language profile that outputs SRT.
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.
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.
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.
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
/opt/data) against its current documentation before you rely on it.Why every app mounts the same /data
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.
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.
- Host
- qbittorrent
- Port
- 8080
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.
You entered localhost as the host. Inside a container, that is the container itself.
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 👍
I only use this for content I'm legally entitled to download. 👍
