Building a Docker Compose Media Server Stack with Plex, Sonarr, and Radarr
We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.
A properly wired media server stack is one of the most satisfying homelab projects you can build. When everything talks to everything else — Sonarr finds a new episode, hands it off to your download client, and Plex picks it up the moment it lands on disk — it feels genuinely magical. In this tutorial I'll walk you through building that exact setup using Docker Compose, with Plex as the player, Sonarr and Radarr handling TV shows and movies respectively, Prowlarr managing your indexers in one place, and qBittorrent as the download client.
What You'll Need Before Starting
I run this stack on a small home server with 16 GB of RAM and a six-core CPU. In practice, the whole thing idles comfortably under 2 GB of RAM — the heavy lifting only happens during Plex transcoding. You'll need:
- A machine running Linux (Ubuntu 22.04 LTS or Debian 12 both work great)
- Docker and Docker Compose v2 installed (
docker compose, not the olddocker-compose) - A Plex account (free) and your Plex claim token from plex.tv/claim
- Enough disk for your media — I keep mine on a separate mount at
/mnt/media
If you're running this on a VPS rather than local hardware, DigitalOcean Droplets are a solid choice for the non-transcoding services. Get dependable uptime with DigitalOcean's 99.99% SLA, simple security tools, and predictable monthly pricing with their virtual machines, called Droplets.
Directory Structure First
Getting the folder layout right before you write a single line of Compose is critical. The entire stack needs to agree on where media lives, and the easiest way to do that with zero permission nightmares is to give every container the same /mnt/media volume and let them each look in their own subdirectory.
mkdir -p /opt/mediastack/{plex,sonarr,radarr,prowlarr,qbittorrent}/config
mkdir -p /mnt/media/{tv,movies,downloads/complete,downloads/incomplete}
# Fix ownership — replace 1000 with your actual UID
sudo chown -R 1000:1000 /mnt/media
sudo chown -R 1000:1000 /opt/mediastack
id in your terminal to find your UID and GID. Setting PUID and PGID consistently across all containers is the single biggest thing you can do to avoid "permission denied" errors on your media files.The Docker Compose File
I prefer to keep everything in one docker-compose.yml and use a .env file for values that change between installs. Here's the full stack. Drop this in /opt/mediastack/docker-compose.yml:
version: "3.9"
networks:
medianet:
driver: bridge
services:
plex:
image: lscr.io/linuxserver/plex:latest
container_name: plex
network_mode: host
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
- VERSION=docker
- PLEX_CLAIM=${PLEX_CLAIM}
volumes:
- /opt/mediastack/plex/config:/config
- /mnt/media/tv:/tv
- /mnt/media/movies:/movies
restart: unless-stopped
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
networks:
- medianet
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- /opt/mediastack/sonarr/config:/config
- /mnt/media:/mnt/media
ports:
- "8989:8989"
restart: unless-stopped
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
networks:
- medianet
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- /opt/mediastack/radarr/config:/config
- /mnt/media:/mnt/media
ports:
- "7878:7878"
restart: unless-stopped
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
networks:
- medianet
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- /opt/mediastack/prowlarr/config:/config
ports:
- "9696:9696"
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
networks:
- medianet
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
- WEBUI_PORT=8080
volumes:
- /opt/mediastack/qbittorrent/config:/config
- /mnt/media/downloads:/downloads
ports:
- "8080:8080"
- "6881:6881"
- "6881:6881/udp"
restart: unless-stopped
Notice that Plex uses network_mode: host. This is intentional — Plex relies on mDNS and a handful of UDP ports for local network discovery that are painful to forward individually through bridge networking. Every other service sits on a shared medianet bridge so they can reach each other by container name.
Create a .env file alongside the Compose file:
# /opt/mediastack/.env
PLEX_CLAIM=claim-xxxxxxxxxxxxxxxxxxxx
Grab your claim token from plex.tv/claim — it expires in four minutes, so have your terminal ready. Then bring the stack up:
cd /opt/mediastack
docker compose up -d
# Watch the logs to confirm everything starts cleanly
docker compose logs -f --tail=50
Wiring the Services Together
Once everything is running, the real work is connecting the pieces. Here's the order I always follow:
1. Configure qBittorrent
Open http://your-server-ip:8080. The default credentials are printed in the container logs on first start — run docker logs qbittorrent | grep "temporary password" to find them. Go to Tools → Options → Downloads and set the Default Save Path to /downloads/complete and the Incomplete Downloads folder to /downloads/incomplete. This matters because Sonarr and Radarr monitor the complete folder and will only import finished downloads.
2. Set Up Prowlarr and Add Indexers
Head to http://your-server-ip:9696. Add your indexers here — Prowlarr will automatically sync them to Sonarr and Radarr, saving you from configuring them twice. Under Settings → Apps, add Sonarr at http://sonarr:8989 and Radarr at http://radarr:7878 using each app's API key (found under Settings → General in each app).
3. Configure Sonarr and Radarr
In both apps, go to Settings → Download Clients and add qBittorrent with host qbittorrent and port 8080. Because all these containers share the medianet network, they resolve each other by container name. Then add your root folders: /mnt/media/tv in Sonarr and /mnt/media/movies in Radarr. These paths must match exactly what's mounted inside each container.
/downloads/complete but Sonarr is told the download client's category path is something different, Sonarr can never find the completed files to import them. Double-check that the path Sonarr sees when it connects to qBittorrent actually exists inside the Sonarr container. Since we mount /mnt/media into every *arr container, using /mnt/media/downloads/complete as the remote path mapping target works cleanly.Enabling Hardware Transcoding in Plex
If your machine has an Intel iGPU, you can pass it through for hardware-accelerated transcoding. Add the following to the plex service in your Compose file (requires Plex Pass):
devices:
- /dev/dri:/dev/dri
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
- VERSION=docker
- PLEX_CLAIM=${PLEX_CLAIM}
Then in Plex web UI under Settings → Transcoder, enable "Use hardware acceleration when available." For AMD GPUs the device is the same /dev/dri path. For NVIDIA you'll need the nvidia-container-toolkit and a slightly different runtime config — that's a whole separate tutorial.
Keeping the Stack Updated with Watchtower
I add Watchtower to almost every Docker Compose project I run. It checks for new image versions and updates containers automatically during off-peak hours. Add this service to the bottom of your Compose file:
watchtower:
image: containrrr/watchtower:latest
container_name: watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_SCHEDULE=0 0 3 * * *
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_INCLUDE_STOPPED=false
restart: unless-stopped
The cron expression 0 0 3 * * * runs the update check at 3 AM every day. WATCHTOWER_CLEANUP=true removes old images after updating, which prevents your disk from filling up with dangling layers over time.
A Note on Running This in the Cloud
I primarily use this stack at home, but I've also run the *arr services and download client on a VPS while streaming back to Plex locally using a reverse proxy. If you want to do something similar, create your DigitalOcean account today — their Droplets start cheap enough that running Sonarr, Radarr, Prowlarr, and a download client on a 2 vCPU / 4 GB instance is perfectly viable. Just make sure you're not violating your VPS provider's terms of service around torrent traffic, and look into using a seedbox provider or a VPN container in your stack if that's a concern.
Wrapping Up
With this stack running you have a fully automated media pipeline: Sonarr and Radarr monitor for new releases, Prowlarr finds them across your indexers, qBittorrent downloads them, and Plex serves everything to your TV, phone, or browser the moment the files land. The whole thing survives reboots via restart: unless-stopped and keeps itself updated with Watchtower.
Your next steps: put a reverse proxy in front of the web UIs so you don't have to remember port numbers (I'd suggest Caddy — one Caddyfile and automatic HTTPS, done), and set up Overseerr or Jellyseerr if you want family members to request content without touching the Sonarr/Radarr UIs directly. Both of those integrate cleanly with this exact stack.
Discussion