Self-Hosted Media Stack, Part 1: The Map Before the Territory
Everyone's first media server setup is the same story: install one app, discover it needs three others, bolt them on one at a time, and end up with a pile of containers that half-know about each other and a downloads folder that copies files instead of moving them. I did it in that order too. This series is the order I should have done it in.
Summary — the stack is a handful of small apps with one job each. Set up the folder structure first, then build outward from the center: Sonarr and Radarr are the brains, so they go in first, and every later part plugs something into them — muscle (downloaders), eyes (indexers), a front door (requests), finishing touches (subtitles), and finally taste (quality tuning). Each part of this series is one layer, with the actual Docker Compose I run.
The cast
| App | Job | Series part |
|---|---|---|
| Sonarr | TV: monitors series, grabs new episodes, renames, upgrades | Part 2 |
| Radarr | Movies: same idea, film-shaped | Part 2 |
| qBittorrent (+ VPN) | Downloads torrents — always through a VPN tunnel | Part 3 |
| SABnzbd | Downloads from Usenet | Part 3 |
| Prowlarr | Manages all your indexers in one place, syncs them to the *arrs | Part 4 |
| Seerr | The request front-end your family actually uses | Part 5 |
| Bazarr | Subtitles, matched to the exact release on disk | Part 6 |
| Profilarr | Syncs curated quality definitions into the *arrs, so "best release" means something | Part 7 |
And one you already have or this is all pointless: a media server (Plex, Jellyfin or Emby) that plays the library the stack maintains.
How the pieces talk
graph LR
U[You / family] -->|request| SEERR[Seerr]
SEERR --> SONARR[Sonarr]
SEERR --> RADARR[Radarr]
PROWLARR[Prowlarr] -->|indexers| SONARR
PROWLARR -->|indexers| RADARR
SONARR -->|send download| QB[qBittorrent + VPN]
SONARR -->|send download| SAB[SABnzbd]
RADARR --> QB
RADARR --> SAB
QB -->|completed| SONARR
SAB -->|completed| RADARR
SONARR -->|import + rename| LIB[(Media library)]
RADARR --> LIB
BAZARR[Bazarr] -->|subtitles| LIB
LIB --> PLEX[Plex / Jellyfin]
The flow that matters: you never touch the downloaders. A request enters at Seerr, Sonarr/Radarr pick a release using indexers Prowlarr gave them, hand it to a download client, watch it finish, then import the file into the library with a clean name. The media server just sees a tidy folder.
The one thing to get right first: paths
Every guide says this and everyone skips it once: all containers must see the media
storage through the same mount, at the same path. Mine is one share mounted as
/Media in every container:
/Media
├── Movies/ # Radarr's root folder, Plex's movie library
├── Series/ # Sonarr's root folder, Plex's TV library
└── downloads/
├── torrents/ # qBittorrent writes here
└── usenet/ # SABnzbd writes here
Why one mount instead of separate /downloads and /media volumes: hardlinks.
When the download folder and the library are on the same filesystem and the same
mount, Sonarr "moves" a finished download by creating a hardlink — instant, free, and
the torrent keeps seeding from the original. Split them across two mounts and every
import becomes a full copy: slow, and briefly double the disk usage of every download.
Two environment variables appear in every compose file in this series:
environment:
- PUID=1001 # a placeholder - use your own, see below
- PGID=100
- TZ=Europe/Amsterdam
PUID/PGID make the container write files as a real user instead of root. Set them
once, consistently, in every service — half of all *arr import errors are permission
mismatches between containers that disagree about who owns /Media.
Use a dedicated user — not root, and not your own login. Create one account that
owns the media tree and nothing else, and run every container in the stack as it. Root
works and is the wrong habit: a container that only ever touches /Media has no
business being able to touch anything else on the box. Your personal account is only
marginally better — everything the stack writes then carries your identity, and you
inherit its mess in every other context you use that account for.
1001 above is a placeholder, not a value to copy. It happens to be the first id
most distributions hand out, so it is a decent guess and quite possibly not yours. Run
id mediauser and use what it actually prints. Copying ids out of someone's blog post
is how you end up with a stack that writes files nothing else can read.
Creating the structure, step by step
On any Linux box (or anything you can SSH into):
# Create the dedicated user, then read its ids - these become PUID/PGID everywhere:
sudo useradd -g users -s /usr/sbin/nologin mediauser
id mediauser
# uid=1001(mediauser) gid=100(users) ...
# The media tree, the per-app config folders, and a home for the compose files:
mkdir -p /srv/media/{Movies,Series,downloads/{torrents,usenet}}
mkdir -p /srv/appdata/{sonarr,radarr,qbittorrent,sabnzbd,prowlarr,bazarr,seerr,profilarr}
mkdir -p /compose
chown -R mediauser:users /srv/media /srv/appdata /compose
On a Synology the shape is identical — DSM just owns the top level. Create the media
shared folder under Control Panel → Shared Folder, add the same subfolders in File
Station, and create a DSM user for the stack: DSM users are Linux users underneath, so
id <user> over SSH gives you the same numbers. App configs go in a second shared
folder, commonly docker — those don't participate in hardlinks, so it can live on any
volume.
🔴 Keep downloads and library in one shared folder. Hardlinks don't cross volumes, and on Synology each shared folder can quietly land on a different one. Split them and every import silently becomes a full copy — with compose files that look perfectly correct.
What /path/to/... means in the rest of the series
Every compose file in parts 2–7 is environment-independent — the right side of each
volume line (:/Media, :/config) never changes. Only the left side is yours:
| Placeholder | Generic Linux | Synology DSM |
|---|---|---|
/path/to/Media | /srv/media | /volume1/Media |
/path/to/appdata/<app> | /srv/appdata/<app> | /volume1/docker/<app> |
| Compose files | /compose/<stack>/compose.yaml | /volume1/docker/compose/<stack>/compose.yaml |
The compose files themselves live under a dedicated compose/ tree, one folder per
stack — parts of this series that belong together (say, the two downloaders) share a
stack folder. Running one is:
docker compose -f /compose/media_downloaders/compose.yaml up -d
On Synology that works identically over SSH, or you can point Container Manager → Project at the stack folder and let DSM run it. Any other Docker host — a NUC, a VM, an old desktop — follows the generic Linux column.
What you need before Part 2
- A box that runs Docker Compose — mine is a Synology NAS, but nothing here is Synology-specific.
- One storage location for media and downloads together (see above).
- For Part 3, a VPN subscription that gives you an OpenVPN or WireGuard config file — non-negotiable for the torrent side — and a Usenet provider account if you want the Usenet side (skip SABnzbd if not).
Next: Part 2 — Sonarr and Radarr, the brains of the operation.
If this saved you some time, a coffee keeps the lights on and the posts coming.
☕ Buy me a coffee