Media Stack¶
What you'll end up with¶
A complete self-hosted media pipeline across three LXCs: Sonarr and Radarr managing your library in CT 300, qBittorrent and SABnzbd downloading in CT 302, and Jellyfin serving everything in CT 301. All three containers write to the same physical directories on the data disk using a shared GID - no NFS, no copies, hardlinks throughout.
Seerr sits on CT 300 as the request frontend, connecting Jellyfin and the *arr stack so you can search for and request content from one place.
Prerequisites¶
- Komodo running
- Traefik running (for service access via subdomain)
- Data disk mounted at
$DATA_ROOTwith media directories created byhost-prep.sh
How the cross-CT file sharing works¶
This is the most important thing to understand before deploying. Three separate LXCs all need read/write access to the same directories on the host ($DATA_ROOT/media-stack/).
The solution: a media group with GID 1300 that exists on the Proxmox host. Each relevant CT uses an lxc.idmap override that maps GID 1300 → 1300 (1:1, no offset). So:
- Sonarr (CT 300) runs as GID 1300 → maps to host GID 1300 ?
- qBittorrent (CT 302) runs as GID 1300 → maps to host GID 1300 ?
- Jellyfin (CT 301) runs as GID 1300 → maps to host GID 1300 ?
All three read and write the same files on the host. No network layer. See Architecture → Cross-CT File Sharing for the full explanation.
The CT scripts handle all the idmap, bind mount, and directory ownership setup automatically.
1. Create the LXCs¶
All three are Docker LXCs:
| CT | Name | Cores | RAM | Disk | IP |
|---|---|---|---|---|---|
| 300 | media-arr | 4 (limit 2) | 4096 MB | 16 GB | 192.168.50.30 |
| 301 | media-server | 4 (limit 3) | 4096 MB | 8 GB | 192.168.50.31 |
| 302 | media-dl | 2 (limit 2) | 1024 MB | 8 GB | 192.168.50.32 |
CT 300 needs 16 GB rootfs
Dispatcharr's all-in-one image is ~3.9 GB alone. With all services, CT 300 fills up fast. 16 GB is the minimum to have headroom for updates.
2. Run the CT scripts¶
cd /opt/proxmox-admin
bash scripts/ct300-media-arr.sh
bash scripts/ct301-media-server.sh
bash scripts/ct302-media-dl.sh
Each script creates the service users and groups on the host, sets directory ownership, configures idmap, applies bind mounts, and installs Periphery.
3. Fill in stack secrets¶
The Unpackerr service in media-arr needs Sonarr and Radarr API keys to unpack completed downloads. These go in the stack .env:
| Variable | Where to get it |
|---|---|
SONARR_URL |
http://192.168.50.30:8989 |
SONARR_API_KEY |
Sonarr → Settings → General → API Key |
RADARR_URL |
http://192.168.50.30:7878 |
RADARR_API_KEY |
Radarr → Settings → General → API Key |
You can deploy the stack first and add these after Sonarr and Radarr are running.
The media-server stack needs credentials for Jellystat (the Jellyfin analytics service):
| Variable | Value |
|---|---|
POSTGRES_PASSWORD |
Random strong password for the Jellystat database |
JWT_SECRET |
Random secret for Jellystat JWT tokens |
4. Deploy stacks via Komodo¶
Add the servers to Komodo first (CT 300, 301, 302), then deploy:
| Server | Stack | Notes |
|---|---|---|
| media-arr | media-arr |
Sonarr, Radarr, Prowlarr, Bazarr, Seerr, Dispatcharr, Unpackerr |
| media-server | media-server |
Jellyfin, Jellystat |
| media-dl | media-dl |
qBittorrent, SABnzbd |
5. Configure Prowlarr (indexers)¶
Prowlarr is the indexer manager - it pushes indexer configs to Sonarr and Radarr so you don't configure indexers in each app separately.
- Open Prowlarr at
https://prowlarr.home.example.com:<admin-port> - Settings → Apps → Add Application: add Sonarr and Radarr with their local URLs and API keys
- Indexers → Add Indexer: add your indexers
- Prowlarr automatically syncs them to Sonarr and Radarr
6. Configure Sonarr and Radarr¶
Download client (both apps, Settings → Download Clients → Add):
| Setting | Value |
|---|---|
| Type | qBittorrent |
| Host | 192.168.50.32 |
| Port | 8080 |
| Category | sonarr (Sonarr) / radarr (Radarr) |
Use the LAN IP - Sonarr and Radarr are in CT 300, qBittorrent is in CT 302, so Docker container names don't work.
Hardlinks (Settings → Media Management):
Enable Use Hardlinks Instead of Copy. This is critical - without hardlinks, every completed download gets copied to the library, doubling your disk usage. With hardlinks, the file exists once on disk with two directory entries (one in /torrents/, one in /media/), and qBittorrent can continue seeding while the file is "in" the library.
Hardlinks work here because /torrents/ (mounted in CT 302) and /media/ (mounted in CT 300 and CT 301) all come from the same host filesystem ($DATA_ROOT/).
7. Configure qBittorrent¶
Open at https://downloads.home.example.com:<admin-port>.
Categories (right-click in category list → Add category):
| Category | Save path |
|---|---|
sonarr |
/torrents/tv/ |
radarr |
/torrents/movies/ |
Seeding limits (Settings → BitTorrent):
- Ratio limit:
1.0 - When ratio reached: Pause (not Remove - lets you review before deletion)
DNS override: qBittorrent uses external DNS (1.1.1.1, 8.8.8.8) in the compose file - Pi-hole blocks many tracker domains, so qBittorrent must bypass it.
8. Configure Jellyfin¶
Open at https://jellyfin.home.example.com and complete the setup wizard.
Libraries (Dashboard → Libraries → Add Media Library):
| Library name | Path | Content type |
|---|---|---|
| TV | /media/tv |
Shows |
| Anime | /media/anime |
Shows |
| Movies | /media/movies |
Movies |
| Movies 4K | /media/movies-4k |
Movies |
Keep Movies and Movies 4K as separate libraries - merging them loses the quality distinction that Seerr uses for routing requests.
9. Configure Seerr¶
Seerr is the request portal - users search for content and request it, which creates tasks in Sonarr/Radarr.
- Open Seerr at
https://seerr.home.example.com - Settings → Jellyfin: connect with
http://192.168.50.31:8096(LAN IP, not through Traefik) and a Jellyfin API key - Settings → Sonarr:
http://192.168.50.30:8989 - Settings → Radarr:
http://192.168.50.30:7878
Use LAN IPs for all connections - everything is on the same network, and going through Traefik adds unnecessary overhead.
Day-2 operations¶
Updating services¶
Update one at a time to avoid filling CT 300's rootfs:
pct exec 300 -- bash -c 'cd /data/komodo/stacks/media-arr && docker compose pull sonarr && docker compose up -d sonarr && docker image prune -f'
Or use Komodo UI: deploy the stack, which pulls all images at once - be aware this can consume significant temp space.
Checking library health¶
- Sonarr/Radarr: System → Status shows health checks for indexers and download clients
- Jellyfin: Dashboard → Libraries → Scan All Libraries after adding new content
Freeing disk space¶
After torrents finish seeding, Sonarr/Radarr can delete them from qBittorrent. Configure in each app under Settings → Download Clients → check "Remove Completed Downloads".
Troubleshooting¶
Permission denied writing to /media/ or /torrents/
Check that the idmap is correctly applied. Inside CT 300:
docker exec sonarr id
# Expected: uid=1300 gid=1300
ls -la /media/
# Expected: drwxrwxr-x ... 1300 1300 tv
If the GID doesn't match, the CT script may not have run, or the idmap needs to be re-applied (stop the CT, verify /etc/pve/lxc/300.conf has the idmap block, start again).
Hardlinks not working
Verify Sonarr/Radarr and qBittorrent are all reading/writing from the same host filesystem. Check the bind mounts: CT 300 must have /media and /torrents mounted from $DATA_ROOT/media-stack/, and CT 302 must have /torrents from the same path.
Test: pct exec 300 -- stat /torrents/tv/somefile.mkv and pct exec 300 -- stat /media/tv/Show/somefile.mkv - the inode number should be identical if hardlinks are working.
qBittorrent not receiving downloads from Sonarr/Radarr
Confirm Sonarr/Radarr are using the IP 192.168.50.32:8080 (not a Docker container name). Container names don't work across LXC boundaries.