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:

  1. A user searches and requests a movie/series in Jellyseerr.
  2. Jellyseerr sends that request to Radarr (movies) or Sonarr (series).
  3. Radarr/Sonarr ask Prowlarr for sources (indexers) and pick a match based on your profiles.
  4. Radarr/Sonarr send the grab request to qBittorrent, which downloads into /mnt/content/downloads.
  5. When the download completes, Radarr/Sonarr rename and move it into /mnt/content/movies or /mnt/content/series.
  6. Jellyfin scans the library paths and makes the new media available to stream.
  7. Bazarr checks the newly added items and pulls subtitles according to your language profile.
  8. 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

  1. Fast and tidy storage: OS on one disk, content on another
  2. Hardened host: SSH keys only, no root SSH, firewall, brute force protection
  3. 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

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: sonarr port 8989
    • Radarr host: radarr port 7878

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.