# Self-Hosted Media Server (Arr Stack w/ Jellyfin)

> a private, automated workflow for your media

By Zsolt Bizderi · Published 2026-02-24
Canonical: https://ambientnode.uk/self-hosted-media-server-arr-stack-w-jellyfin

## \*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](https://www.qbittorrent.org/) in this build)
* Index manager: knows where to search and how to talk to your download client ([Prowlarr](https://prowlarr.com/))
* Library managers: keep your movie and TV libraries organised, renamed, moved, and monitored ([Radarr](https://radarr.video/) + [Sonarr](https://sonarr.tv/))
* Player: streams your library to TVs, phones, etc ([Jellyfin](https://jellyfin.org/))
* Requests portal: a nice UI for requesting new content and tracking it ([Jellyseerr](https://docs.seerr.dev/))
* Subtitles: manages subtitles once content lands ([Bazarr](https://www.bazarr.media/))

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](https://github.com/Ravencentric/awesome-arr), 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 Thought**s

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](/wireguard-vpn/) / [Tailscale](/tailscale-vpn/) or a [tunnel](/pangolin-self-hosted-cloudflare-tunnel-alternative/). 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.
