Self-Hosted Media Server (Arr Stack w/ Jellyfin)
a private, automated workflow for your media
*Arr Stack?
An *Arr stack is a set of self hosted services that automate a personal media workflow end to end:
- Download: pulls files you request (qBittorrent in this build)
- Index manager: knows where to search and how to talk to your download client (Prowlarr)
- Library managers: keep your movie and TV libraries organised, renamed, moved, and monitored (Radarr + Sonarr)
- Player: streams your library to TVs, phones, etc (Jellyfin)
- Requests portal: a nice UI for requesting new content and tracking it (Jellyseerr)
- Subtitles: manages subtitles once content lands (Bazarr)
This essentially creates a one-stop-shop for all media requests, media organisation and consumption that is fully automated.
There are a ton more *arr apps, listed here, that are not part of this guide.
Obviously, this needs to be mentioned: this setup is for automating file organisation and playback. The legal use case is to stream and manage media you already own, your own rips/backups, public domain content, or content you are licensed to download/store in your region, respecting copyright.
Jellyfin vs Plex
Plex and Jellyfin solve the same problem: they scan your media library, pull metadata, and stream to your devices. Plex is polished and widely supported, but it is designed around a Plex account and their ecosystem. Even though your media is hosted locally, parts of the experience still depend on a third party service.
Jellyfin is fully self hosted and does not require a vendor account. Authentication and access can stay entirely on your infrastructure (LAN, VPN, reverse proxy), and it keeps working even if the internet or a third party service has issues.
I personally prefer Jellyfin as it stays under my control end to end, with no reliance on Plex as a third party.
Workflow
Once everything is connected, the stack behaves like a pipeline:
- A user searches and requests a movie/series in Jellyseerr.
- Jellyseerr sends that request to Radarr (movies) or Sonarr (series).
- Radarr/Sonarr ask Prowlarr for sources (indexers) and pick a match based on your profiles.
- Radarr/Sonarr send the grab request to qBittorrent, which downloads into
/mnt/content/downloads. - When the download completes, Radarr/Sonarr rename and move it into
/mnt/content/moviesor/mnt/content/series. - Jellyfin scans the library paths and makes the new media available to stream.
- Bazarr checks the newly added items and pulls subtitles according to your language profile.
- You can use one of the many Jellyfin clients on your TV, phone, or web to stream the content.
And that's the "Arr stack" loop: request, source, download, organise, stream.
Build goals for this setup
- Fast and tidy storage: OS on one disk, content on another
- Hardened host: SSH keys only, no root SSH, firewall, brute force protection
- Clean filesystem layout and containers for everything, ensuring easy upgrades, easy backups, easy redeploy
1) Storage design
Keep media off the OS disk to make it easy to rebuild and avoid IO heavy workloads on a single disk. This also allows for the media disk to be replaced/expanded without touching the OS. Target layout:
- OS drive: Ubuntu Server + Docker + configs
- Content drive:
/mnt/content/downloads,/mnt/content/movies,/mnt/content/series
2) Install Ubuntu Server
- Install Ubuntu Server LTS
- During install:
- Set hostname (example: mediaserver)
- Create an initial admin user (temporary is fine)
- Install OpenSSH Server when prompted. Note that in newer Ubuntu builds, OpenSSH remains disabled, so you will need to enable it after you sign in for the first time.
After first boot:
sudo apt update && sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg ufw
sudo reboot
3) Basic hardening: SSH access, jump user, keys only
Create two users
jump= SSH entry user (no sudo, SSH only)arrsvc= admin/service user (sudo, runs docker, owns app configs)
sudo adduser jump
sudo adduser arrsvc
sudo usermod -aG sudo arrsvc
Set up SSH keys
From your workstation:
ssh-keygen -t ed25519 -C "arr-stack"
ssh-copy-id jump@YOUR_SERVER_IP
ssh-copy-id arrsvc@YOUR_SERVER_IP
Lock down SSH server config
Edit /etc/ssh/sshd_config:
sudo nano /etc/ssh/sshd_config
Set (or ensure) these values exist:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
UsePAM yes
AllowUsers jump arrsvc
Restart SSH:
sudo systemctl restart ssh
Important: keep your current session open while you test a new session. Confirm you can login via key auth before closing anything as you may get locked out otherwise.
4) Connect the external drive, format it, mount it, create folders
Identify the disk
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL
Assume the external disk is /dev/sdb (yours may differ). Double check before formatting.
Format (example: ext4)
sudo wipefs -a /dev/sdb
sudo parted -s /dev/sdb mklabel gpt
sudo parted -s /dev/sdb mkpart primary ext4 0% 100%
sudo mkfs.ext4 -L content /dev/sdb1
Mount it under /mnt/content
sudo mkdir -p /mnt/content
sudo blkid /dev/sdb1
Copy the UUID, then add to /etc/fstab:
sudo nano /etc/fstab
Add a line like:
UUID=YOUR_UUID_HERE /mnt/content ext4 defaults,noatime 0 2
Mount and verify:
sudo mount -a
df -h | grep /mnt/content
Create the content folders
sudo mkdir -p /mnt/content/{downloads,movies,series}
Permissions
LinuxServer containers expect the host paths to be writable by the user matching PUID/PGID. We will run with arrsvc.
Get IDs:
id arrsvc
Set ownership:
sudo chown -R arrsvc:arrsvc /mnt/content
sudo chmod -R 775 /mnt/content
5) Additional hardening: UFW firewall, Fail2ban, CrowdSec
UFW firewall
Start restrictive, then allow what you actually need.
sudo ufw default deny incoming
sudo ufw default allow outgoing
# SSH
sudo ufw allow OpenSSH
# Jellyfin
sudo ufw allow 18096/tcp
sudo ufw allow 18920/tcp
sudo ufw allow 17359/udp
sudo ufw allow 11900/udp
# Radarr / Sonarr / Prowlarr / Bazarr
sudo ufw allow 17878/tcp
sudo ufw allow 18989/tcp
sudo ufw allow 19696/tcp
sudo ufw allow 16767/tcp
# qBittorrent WebUI + torrent port
sudo ufw allow 18081/tcp
sudo ufw allow 16881/tcp
You may later want to restrict access to these ports (for example, only allow your home IP ranges or VPN subnets), and put everything behind a reverse proxy / VPN / tunnel instead of exposing multiple service ports directly.
Fail2ban
sudo apt -y install fail2ban
sudo systemctl enable --now fail2ban
Create a basic override:
sudo nano /etc/fail2ban/jail.d/sshd.local
Example:
[sshd]
enabled = true
maxretry = 5
findtime = 10m
bantime = 1h
Restart:
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
CrowdSec (+ firewall bouncer)
curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | sudo bash
sudo apt -y install crowdsec crowdsec-firewall-bouncer-iptables
sudo systemctl enable --now crowdsec
sudo cscli metrics
If you use UFW heavily, keep an eye on how you want traffic blocked (iptables bouncer is fine for most setups).
6) Install Docker + Docker Compose (the simple one liner)
As arrsvc:
su - arrsvc
Install Docker:
curl -fsSL https://get.docker.com | sh
Add your user to the docker group:
sudo usermod -aG docker arrsvc
newgrp docker
Install Compose plugin (usually included now anyway):
sudo apt -y install docker-compose-plugin
docker compose version
7) Deploy the Arr stack with Docker Compose
Directory structure
You asked for:
- Compose under:
/home/<rootuseryoucreate>/arr
We are using arrsvc as that user:
mkdir -p /home/arrsvc/arr
cd /home/arrsvc/arr
Create docker-compose.yml
nano /home/arrsvc/arr/docker-compose.yml
Paste this compose file and change your timezone and Jellyfin domain.
services:
jellyfin:
image: lscr.io/linuxserver/jellyfin:latest
container_name: jellyfin
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
- JELLYFIN_PublishedServerUrl=https://jellyfin.mydomain.com
volumes:
- /home/arrsvc/arr/jellyfin:/config
- /mnt/content/series:/data/series
- /mnt/content/movies:/data/movies
ports:
- 18096:8096
- 18920:8920
- 17359:7359/udp
- 11900:1900/udp
restart: always
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- /home/arrsvc/arr/radarr/data:/config
- /mnt/content/movies:/movies
- /mnt/content/downloads:/downloads
ports:
- 17878:7878
restart: always
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- /home/arrsvc/arr/sonarr/data:/config
- /mnt/content/series:/tv
- /mnt/content/downloads:/downloads
ports:
- 18989:8989
restart: always
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- /home/arrsvc/arr/prowlarr/data:/config
ports:
- 19696:9696
restart: always
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
- WEBUI_PORT=8080
- TORRENTING_PORT=6881
volumes:
- /home/arrsvc/arr/qbit:/config
- /mnt/content/downloads:/downloads
ports:
- 18081:8080
- 16881:6881
- 16881:6881/udp
restart: always
jellyseerr:
image: fallenbagel/jellyseerr:latest
container_name: jellyseerr
environment:
- TZ=Europe/London
ports:
- 15056:5055
volumes:
- /home/arrsvc/arr/jellyseerr:/app/config
restart: always
bazarr:
image: lscr.io/linuxserver/bazarr:latest
container_name: bazarr
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- /home/arrsvc/arr/bazarr:/config
- /mnt/content/movies:/movies
- /mnt/content/series:/tv
ports:
- 16767:6767
restart: always
watchtower:
image: containrrr/watchtower
container_name: watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: always
I left PUID=1000 / PGID=1000 because on a fresh Ubuntu install your first real user is usually 1000. If id arrsvc shows different numbers, change those values to match or you will hit permissions issues on /mnt/content and /home/arrsvc/arr/*.
Bring it up
From /home/arrsvc/arr:
docker compose pull
docker compose up -d
docker ps
Visit the services:
- Jellyfin:
http://SERVER_IP:18096 - Radarr:
http://SERVER_IP:17878 - Sonarr:
http://SERVER_IP:18989 - Prowlarr:
http://SERVER_IP:19696 - qBittorrent:
http://SERVER_IP:18081 - Jellyseerr:
http://SERVER_IP:15056 - Bazarr:
http://SERVER_IP:16767
If something doesn't start, check the logs:
docker logs -n 200 radarr
docker logs -n 200 sonarr
docker logs -n 200 qbittorrent
Setup and service connections
At this stage Docker is running, the containers are up, and you can hit the web UIs on the ports we defined. The order matters because later services need API keys and connections from earlier ones.
1) qBittorrent
Open qBittorrent first:
http://SERVER_IP:18081
LinuxServer’s qBittorrent container generates a temporary password on first start. Pull it from logs:
docker logs -n 200 qbittorrent
Look for the WebUI credentials line, then log in. Once you're in, change the WebUI username and password.
2) Jellyfin
Next, visit Jellyfin at http://SERVER_IP:18096 and walk through the initial setup:
- Create an admin account. This is your "server owner" account, not the one you log in with every day.
- Add libraries:
- Movies:
/data/movies - Shows:
/data/series
- Movies:
Those are container paths that map to /mnt/content/movies and /mnt/content/series on the host.
After setup, create your normal day to day Jellyfin user and make it non admin. You can also hide the admin user from the login screen and only use it when you need to change server settings.
3) Prowlarr
Now Prowlarr: http://SERVER_IP:19696
Start with authentication. If you don't want Prowlarr to require login on your internal network, you can switch auth mode in its config to External (under /arr/prowlarr/data) and restart the container, but in general I leave auth on and restrict access by firewall/VPN.
Once you're in, configure three things. Because everything is on the same Docker network, you can use container DNS names instead of IPs:
A) Connect qBittorrent as a download client
Go to Settings > Download Clients and add qBittorrent (using the username/password you set in qBittorrent earlier)
B) Connect Radarr + Sonarr
- Go to Settings > Apps
- Add Radarr and Sonarr
- You'll need the API keys from each app:
- In Radarr/Sonarr: Settings > General > API Key
C) Add indexers
Go to Indexers and add what you use. I'm not documenting which ones here because legality varies massively by region and source, and the whole point of this post is a clean, legit automation stack. Use sources you are licensed to use, and keep it legal.
Once Prowlarr is done, it will push indexers and download client settings into Radarr and Sonarr, which saves you duplicating config.
4) Radarr and Sonarr:
Radarr: http://SERVER_IP:17878
Sonarr: http://SERVER_IP:18989
Do the same setup pattern in both.
Authentication
Create a user and enable auth. If you’re keeping everything behind VPN/reverse proxy, you can loosen this later.
Root folders
- Radarr root folder:
/movies(container path mapped to/mnt/content/movies)
- Sonarr root folder:
/tv(container path mapped to/mnt/content/series)
Download client
If Prowlarr already pushed qBittorrent into Radarr/Sonarr, verify it. If not, add it manually under Settings > Download Clients.
5) Bazarr:
Bazarr simply watches your library and pulls subtitles based on your rules.
Bazarr: http://SERVER_IP:16767
Configure it like this:
A) Connect Sonarr and Radarr
- In Bazarr settings, connect to Sonarr and Radarr using their API keys.
- Use Docker service names for hosts:
- Sonarr host:
sonarrport8989 - Radarr host:
radarrport7878
- Sonarr host:
Bazarr needs to understand where Sonarr/Radarr think files live versus where Bazarr sees them. Scroll down to the bottom of the page to define the paths.
C) Providers and languages
- Add subtitle providers under Providers
- Then set languages:
- Go to Languages
- Add your language(s)
- Create a profile and set defaults separately for Movies and Series
6) Jellyseerr:
Finally Jellyseerr: http://SERVER_IP:15056
Jellyseerr sits at the front of the workflow. Users request, Jellyseerr sends it to Radarr/Sonarr, those grab releases via Prowlarr and download via qBittorrent, then Jellyfin serves it.
A) Connect Jellyfin
- Point Jellyseerr at your Jellyfin URL (internal DNS)
- It will automatically create an API key in Jellyfin during the integration
B) Connect Radarr and Sonarr
- Add both, provide their URLs and API keys
- Select which types of media Jellyseerr is allowed to request
- Set quality profiles if you want stricter control (recommended)
C) Auth model
Jellyseerr can use Jellyfin accounts (SSO-style). Use this if Jellyfin is your source of truth. You can disable local Jellyseerr sign-in under Settings > Users so nobody ends up with separate credentials.
By default, admin approves requests
- You can change this under Settings > Auto-Approve
- If it’s just you at home, auto-approve is fine. If you have family members requesting everything under the sun, approvals help keep storage under control.
Final Thoughts
If you made it this far, you now have a fully functional Arr stack!
A note on security: Treat these services like internal tooling, not something to expose raw to the internet. If you do want remote access, use a VPN like Wireguard / Tailscale or a tunnel. Also, from here you can improve quality of life:
- Add a reverse proxy + SSO instead of remembering ports.
- Add monitoring (Uptime Kuma, Prometheus, whatever you use).
- Add a backup routine for config and metadata.
- Tune quality profiles and naming so your library stays consistent.
And that's it. The workflow is now basically: request in Jellyseerr, watch in Jellyfin, everything else happens in the background.