Building a Docker Compose Media Server Stack with Plex, Sonarr, and Radarr

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:

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
Tip: Run 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.

Watch out: The most common mistake I see is mismatched paths between the download client and the *arr apps. If qBittorrent saves to /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