The Complete Mediastack Guide
From Zero to Automated

Build a fully automated media server with Docker — movies, TV shows, music, and subtitles discovered, downloaded, organized, and served automatically.

~2 hours to deploy Docker Compose VPN-protected Beginner-friendly

1. What Is a Mediastack?

A mediastack is a collection of self-hosted applications that work together to:

  1. Find media (movies, TV shows, music) on torrent indexers
  2. Download it through a VPN-protected torrent client
  3. Organize it into a clean library with proper naming
  4. Serve it to your devices via a media server (like Plex or Jellyfin)
  5. Fetch subtitles automatically
  6. Let users request new content from a friendly web UI

All of this happens automatically. You (or your family) request a movie, and it appears in Plex within minutes — downloaded, renamed, moved to the right folder, with subtitles.

The apps and what they do

AppRoleOne-sentence summary
GluetunVPN containerRuns a VPN tunnel that other containers route traffic through
qBittorrentDownload clientDownloads torrents through the VPN
ProwlarrIndexer managerConnects to torrent sites and shares them with Sonarr/Radarr/Lidarr
SonarrTV show managerMonitors TV shows, searches for new episodes, sends them to qBittorrent
RadarrMovie managerSame as Sonarr but for movies
LidarrMusic managerSame as Sonarr but for music albums
BazarrSubtitle managerAutomatically downloads subtitles for everything Sonarr and Radarr grab
PlexMedia serverServes your library to TVs, phones, and browsers
SeerrRequest managerPretty UI where users can browse and request movies/shows
RecyclarrQuality syncAutomatically applies community-recommended quality profiles
FlareSolverrCAPTCHA solverHelps Prowlarr access indexers that use Cloudflare protection

2. How the Pieces Fit Together

Here's the flow when someone requests a movie:

User opens Seerr → "I want to watch Interstellar" │ ▼ Seerr sends request to Radarr │ ▼ Radarr asks Prowlarr: "Find Interstellar in 1080p Bluray" │ ▼ Prowlarr searches your configured torrent indexers │ ▼ Prowlarr returns results to Radarr │ ▼ Radarr picks the best match and sends it to qBittorrent │ ▼ qBittorrent downloads it through Gluetun (VPN-protected) │ ▼ qBittorrent finishes → Radarr detects completion │ ▼ Radarr hardlinks the file to your media library: /data/media/movies/Interstellar (2014)/Interstellar (2014).mkv │ ▼ Plex detects the new file and adds it to your library │ ▼ Bazarr detects the new movie and downloads subtitles │ ▼ User gets a notification and watches Interstellar

The VPN layer

All torrent traffic goes through Gluetun. The *arr apps (Sonarr, Radarr, etc.) and qBittorrent all run on a Docker network that routes through Gluetun's VPN tunnel. If the VPN drops, traffic stops — nothing leaks to your real IP.

┌─────────────────────────────────────────────────┐ │ VPN Network (172.21.0.0/16) │ │ │ │ ┌──────────┐ │ │ │ Gluetun │← All internet traffic routed │ │ │ (VPN) │ through VPN tunnel │ │ └────┬─────┘ │ │ │ │ │ ┌────┴──────┐ ┌─────────┐ ┌────────┐ │ │ │qBittorrent│ │ Sonarr │ │ Radarr │ ... │ │ │ :8090 │ │ :8989 │ │ :7878 │ │ │ └───────────┘ └─────────┘ └────────┘ │ │ │ └──────────────────────────────────────────────────┘

Gluetun exposes ports for all the services behind it. When you access Sonarr at http://your-server:8989, the request goes: your browser → Gluetun (port 8989) → Sonarr container. But when Sonarr searches indexers, the traffic goes: Sonarr → Gluetun → VPN tunnel → internet.

3. Prerequisites

Hardware

Software

Accounts

User/Group IDs

Every container needs to run with the same user and group IDs so file permissions work. Find yours:

id
# Output: uid=1000(youruser) gid=1000(youruser) groups=...

Note your uid (PUID) and gid (PGID). You'll use these in every container.

4. Folder Structure (This Is Critical)

This is the single most important section of this guide
Get the folder structure wrong and you'll have broken hardlinks, double disk usage, and slow imports. Get it right and everything just works.

The problem with separate mounts

Many guides mount /downloads and /media as separate volumes in each container. This breaks hardlinks. When Sonarr "moves" a completed download to your library, it should create a hardlink (same file, two paths, zero extra disk space). But hardlinks only work within the same filesystem/mount. Separate mounts = separate filesystems = no hardlinks = files get copied instead, using double the disk space.

The solution: one shared mount

All containers get the same top-level /data mount. Inside it, downloads and media are subdirectories:

/data/ ← One mount, shared by ALL containers ├── media_stack/ │ └── qbittorrent/ │ ├── movies/ ← qBittorrent downloads movies here │ ├── tv/ ← qBittorrent downloads TV here │ └── music/ ← qBittorrent downloads music here └── media/ ├── movies/ ← Radarr's library (hardlinked from above) │ └── Interstellar (2014)/ │ └── Interstellar (2014).mkv ├── tv/ ← Sonarr's library │ └── Breaking Bad/ │ └── Season 01/ │ └── Breaking Bad - S01E01 - Pilot.mkv └── music/ ← Lidarr's library └── Pink Floyd/ └── The Dark Side of the Moon (1973)/ └── 01 - Speak to Me.flac

Create the structure

# Create the directory tree
sudo mkdir -p /data/media_stack/qbittorrent/{movies,tv,music}
sudo mkdir -p /data/media/{movies,tv,music}

# Set ownership to your PUID:PGID
sudo chown -R 1000:1000 /data

How each container sees it

Every container mounts the same /data directory:

volumes:
  - /path/to/your/storage:/data    # Same mount in EVERY container
ContainerWhat it uses inside /data
qBittorrentDownloads to /data/media_stack/qbittorrent/{movies,tv,music}
SonarrSees downloads at /data/media_stack/qbittorrent/tv, library at /data/media/tv
RadarrSees downloads at /data/media_stack/qbittorrent/movies, library at /data/media/movies
LidarrSees downloads at /data/media_stack/qbittorrent/music, library at /data/media/music
PlexReads from /data/media/{movies,tv,music}

Because they all share the same mount, hardlinks work. When Radarr imports a completed movie, it creates a hardlink from the download folder to the library folder — instant, zero extra disk space. The original stays in the download folder for seeding.

5. The VPN — Gluetun

Gluetun is a lightweight container that establishes a VPN tunnel. Other containers connect to the internet through Gluetun by using Docker's network_mode: service:gluetun.

What it does

Why port forwarding matters

Without port forwarding, you can download but can't seed properly. Other peers can't initiate connections to you, so your upload speed (and ratio on private trackers) suffers. PIA, Mullvad, and AirVPN support port forwarding through Gluetun.

Configuration

compose.yaml (gluetun service)
gluetun:
  image: qmcgaw/gluetun:latest
  container_name: gluetun
  restart: unless-stopped
  cap_add:
    - NET_ADMIN          # Required — Gluetun creates network interfaces
  environment:
    - VPN_SERVICE_PROVIDER=private internet access
    - OPENVPN_USER=your-vpn-username
    - OPENVPN_PASSWORD=your-vpn-password
    - SERVER_REGIONS=Netherlands    # Pick a region close to you
    - PORT_FORWARD_ONLY=true        # Only connect to servers that support port forwarding
    - VPN_PORT_FORWARDING=on        # Enable port forwarding
    - UPDATER_PERIOD=24h            # Update server list daily
    - TZ=Europe/Copenhagen
  volumes:
    - /srv/docker/gluetun:/gluetun
  ports:
    # Gluetun exposes ports for ALL services behind the VPN.
    # These services don't have their own ports: section.
    - "8090:8090"     # qBittorrent Web UI
    - "6881:6881"     # qBittorrent torrent port
    - "8989:8989"     # Sonarr
    - "7878:7878"     # Radarr
    - "8686:8686"     # Lidarr
    - "9696:9696"     # Prowlarr
    - "6767:6767"     # Bazarr
    - "7474:7474"     # Autobrr
    - "8191:8191"     # FlareSolverr
  networks:
    vpn:
      ipv4_address: 172.21.0.2
Key concept: ports on Gluetun, not on services
Because other containers route through Gluetun, their ports are exposed on the Gluetun container, not on themselves. This is the #1 thing that confuses beginners.

Port forwarding sync

When your VPN provider assigns a forwarded port, qBittorrent needs to know about it. This script syncs the port from Gluetun's control API to qBittorrent:

#!/bin/bash
# port-sync.sh — run every 5 minutes via cron or as a sidecar container
GLUETUN_API="http://localhost:8000"
QBIT_API="http://localhost:8090"
QBIT_USER="admin"
QBIT_PASS="your-qbit-password"

# Get forwarded port from Gluetun
PORT=$(curl -sf "${GLUETUN_API}/v1/openvpn/portforwarded" | jq -r '.port')

if [ -z "$PORT" ] || [ "$PORT" = "0" ]; then
  echo "No forwarded port available"
  exit 1
fi

# Log into qBittorrent
COOKIE=$(curl -sf -c - "${QBIT_API}/api/v2/auth/login" \
  --data "username=${QBIT_USER}&password=${QBIT_PASS}" | grep SID | awk '{print $NF}')

# Set the listening port
curl -sf "${QBIT_API}/api/v2/app/setPreferences" \
  --cookie "SID=${COOKIE}" \
  --data "json={\"listen_port\":${PORT}}"

echo "Set qBittorrent listening port to ${PORT}"

6. The Download Client — qBittorrent

qBittorrent does the actual downloading. It runs behind Gluetun so all torrent traffic goes through the VPN.

compose.yaml (qbittorrent service)
qbittorrent:
  image: lscr.io/linuxserver/qbittorrent:latest
  container_name: qbittorrent
  restart: unless-stopped
  network_mode: service:gluetun    # Route ALL traffic through Gluetun
  depends_on:
    gluetun:
      condition: service_healthy
  environment:
    - PUID=1000
    - PGID=1000
    - TZ=Europe/Copenhagen
    - UMASK=022
    - WEBUI_PORT=8090
  volumes:
    - /srv/docker/qbittorrent:/config
    - /data:/data

Notice: no ports: section. qBittorrent's ports are exposed on the Gluetun container instead.

network_mode: service:gluetun means qBittorrent shares Gluetun's network stack. When qBittorrent connects to a peer, it goes through the VPN tunnel. When you access http://your-server:8090, the request goes to Gluetun's port 8090, which routes to qBittorrent.

First-time setup

  1. Open http://your-server:8090
  2. Check logs for temp password: docker logs qbittorrent 2>&1 | grep "temporary password"
  3. Change the password in Settings → Web UI
  4. Settings → Downloads → Default Save Path: /data/media_stack/qbittorrent/
  5. Settings → BitTorrent → Enable DHT, PeX, Local Peer Discovery (unless private trackers forbid it)

Categories

Set up download categories so Sonarr/Radarr downloads go to the right subfolder:

CategorySave Path
tv/data/media_stack/qbittorrent/tv
movies/data/media_stack/qbittorrent/movies
music/data/media_stack/qbittorrent/music

Create these in qBittorrent: right-click in the left sidebar → "New Category".

7. The Indexer Manager — Prowlarr

Prowlarr is the central place where you configure your torrent indexers. Instead of adding each indexer separately to Sonarr, Radarr, and Lidarr, you add them once in Prowlarr and it syncs them to all the *arr apps.

compose.yaml (prowlarr service)
prowlarr:
  image: lscr.io/linuxserver/prowlarr:latest
  container_name: prowlarr
  restart: unless-stopped
  network_mode: service:gluetun
  depends_on:
    gluetun:
      condition: service_healthy
  environment:
    - PUID=1000
    - PGID=1000
    - TZ=Europe/Copenhagen
  volumes:
    - /srv/docker/prowlarr/config:/config

First-time setup

  1. Open http://your-server:9696
  2. Set authentication: Settings → General → Authentication
  3. Add indexers: Indexers → Add Indexer → search for your tracker, enter credentials, test
  4. Connect to *arr apps: Settings → Apps → Add Application
    • Sonarr: URL http://localhost:8989, API key from Sonarr
    • Radarr: URL http://localhost:7878, API key from Radarr
    • Lidarr: URL http://localhost:8686, API key from Lidarr
Why localhost?
All these containers share Gluetun's network via network_mode: service:gluetun. From Prowlarr's perspective, Sonarr is on the same host (localhost), just a different port.

FlareSolverr

Some indexers use Cloudflare protection. FlareSolverr solves CAPTCHAs for Prowlarr:

flaresolverr:
  image: ghcr.io/flaresolverr/flaresolverr:latest
  container_name: flaresolverr
  restart: unless-stopped
  network_mode: service:gluetun
  environment:
    - TZ=Europe/Copenhagen

In Prowlarr: Settings → Indexers → Add → FlareSolverr → URL: http://localhost:8191

8. TV Shows — Sonarr

Sonarr manages your TV show library. You tell it which shows you want, and it monitors indexers for new episodes, downloads them, renames them, and moves them to your library.

compose.yaml (sonarr service)
sonarr:
  image: lscr.io/linuxserver/sonarr:latest
  container_name: sonarr
  restart: unless-stopped
  network_mode: service:gluetun
  depends_on:
    gluetun:
      condition: service_healthy
  environment:
    - PUID=1000
    - PGID=1000
    - TZ=Europe/Copenhagen
    - UMASK=022
  volumes:
    - sonarr_config:/config     # Local volume (see note below)
    - /data:/data
Why a Docker volume instead of a bind mount?
Sonarr uses SQLite for its database. SQLite + NFS = "database is locked" errors because NFS doesn't handle file locking properly. If your config storage is on NFS, use a local Docker volume (sonarr_config). If your storage is local (SSD, HDD), a bind mount (/srv/docker/sonarr:/config) is fine.

First-time setup

  1. Open http://your-server:8989
  2. Set authentication: Settings → General → Authentication → Forms
  3. Add root folder: Settings → Media Management → Root Folders → Add/data/media/tv
  4. Add download client: Settings → Download Clients → Add → qBittorrent → Host: localhost, Port: 8090, Category: tv
  5. Note your API key: Settings → General → API Key (needed for Prowlarr and Bazarr)
  6. Add a show: Series → Add New

How importing works

  1. qBittorrent downloads an episode to /data/media_stack/qbittorrent/tv/
  2. Sonarr detects the completed download (polls qBittorrent every 60 seconds)
  3. Sonarr creates a hardlink from the download to /data/media/tv/Show Name/Season XX/
  4. The file now exists at both paths but uses disk space only once
  5. The original stays for seeding; when done, Sonarr can optionally delete it

9. Movies — Radarr

Radarr is Sonarr but for movies. The setup is nearly identical.

compose.yaml (radarr service)
radarr:
  image: lscr.io/linuxserver/radarr:latest
  container_name: radarr
  restart: unless-stopped
  network_mode: service:gluetun
  depends_on:
    gluetun:
      condition: service_healthy
  environment:
    - PUID=1000
    - PGID=1000
    - TZ=Europe/Copenhagen
    - UMASK=022
  volumes:
    - radarr_config:/config
    - /data:/data

First-time setup

  1. Open http://your-server:7878
  2. Set authentication: Settings → General → Authentication → Forms
  3. Add root folder: /data/media/movies
  4. Add download client: qBittorrent on localhost:8090, category movies
  5. Note your API key for Prowlarr and Bazarr
  6. Add a movie: Movies → Add New

Quality profiles determine which releases to grab (Bluray 1080p vs WEB-DL, etc.). The defaults are okay, but Recyclarr (section 14) syncs much better community profiles.

10. Music — Lidarr

Lidarr manages your music library. Same pattern as Sonarr/Radarr.

compose.yaml (lidarr service)
lidarr:
  image: lscr.io/linuxserver/lidarr:latest
  container_name: lidarr
  restart: unless-stopped
  network_mode: service:gluetun
  depends_on:
    gluetun:
      condition: service_healthy
  environment:
    - PUID=1000
    - PGID=1000
    - TZ=Europe/Copenhagen
    - UMASK=022
  volumes:
    - /srv/docker/lidarr/config:/config
    - /data:/data

First-time setup

  1. Open http://your-server:8686
  2. Set authentication
  3. Add root folder: /data/media/music
  4. Add download client: qBittorrent on localhost:8090, category music
  5. Add an artist: Artist → Add New
Lidarr is the weakest link
Music indexing is harder than TV/movies. Lidarr can be finicky with matching releases. It works best with private music trackers. For casual music libraries, you might skip Lidarr and just use Plex's built-in music scanner with manually organized files.

11. Subtitles — Bazarr

Bazarr automatically downloads subtitles for your movies and TV shows. It integrates with Sonarr and Radarr — when a new file is imported, Bazarr checks for subtitles.

compose.yaml (bazarr service)
bazarr:
  image: lscr.io/linuxserver/bazarr:latest
  container_name: bazarr
  restart: unless-stopped
  network_mode: service:gluetun
  depends_on:
    gluetun:
      condition: service_healthy
  environment:
    - PUID=1000
    - PGID=1000
    - TZ=Europe/Copenhagen
    - UMASK=022
  volumes:
    - /srv/docker/bazarr/config:/config
    - /data:/data

First-time setup

  1. Open http://your-server:6767
  2. Connect to Sonarr: Settings → Sonarr → URL: http://localhost:8989, API key
  3. Connect to Radarr: Settings → Radarr → URL: http://localhost:7878, API key
  4. Add subtitle providers: Settings → Providers (OpenSubtitles.com, Addic7ed, Gestdown)
  5. Set languages: Settings → Languages
  6. Enable automatic search: toggle "Search for Missing Subtitles"

Bazarr scores subtitle matches and picks the best one. It can also upgrade subtitles if a better match appears later.

12. The Media Server — Plex

Plex serves your media library to your devices. It runs on the host network for best performance and DLNA support.

compose.yaml (plex service)
plex:
  image: lscr.io/linuxserver/plex:latest
  container_name: plex
  restart: unless-stopped
  network_mode: host               # Direct access to network (no Docker NAT)
  environment:
    - PUID=1000
    - PGID=1000
    - TZ=Europe/Copenhagen
    - VERSION=docker
    - PLEX_CLAIM=claim-xxxxxxxxxxxx   # Get from https://plex.tv/claim
  volumes:
    - /srv/docker/plex/config:/config
    - /data/media:/data/media
  devices:
    - /dev/dri:/dev/dri               # GPU for hardware transcoding

Key points

First-time setup

  1. Open http://your-server:32400/web
  2. Sign in with your Plex account
  3. Add libraries: Movies (/data/media/movies), TV Shows (/data/media/tv), Music (/data/media/music)
Plex does NOT run behind the VPN
It needs direct internet access for remote streaming, account authentication, and metadata fetching. Only torrent-related services go through the VPN.

13. Requests — Seerr

Seerr (formerly Overseerr/Jellyseerr) is a user-friendly request interface. Instead of telling your family "go to Radarr and add a movie", you give them a Netflix-like UI where they can browse trending content and click "Request".

compose.yaml (seerr service)
seerr:
  image: ghcr.io/fallenbagel/jellyseerr:latest
  container_name: seerr
  restart: unless-stopped
  environment:
    - TZ=Europe/Copenhagen
  volumes:
    - /srv/docker/jellyseerr:/app/config
  ports:
    - "5055:5055"
Seerr does NOT need VPN
It only talks to your Sonarr/Radarr APIs and external metadata sources (TMDB). It doesn't touch torrents.

First-time setup

  1. Open http://your-server:5055
  2. Sign in with your Plex account
  3. Connect to Plex: enter your server URL
  4. Connect to Radarr: URL http://your-server-ip:7878, API key, root folder /data/media/movies
  5. Connect to Sonarr: URL http://your-server-ip:8989, API key, root folder /data/media/tv
Note the different URL format
Seerr is NOT on the VPN network, so it can't use localhost to reach Sonarr/Radarr. Use your server's LAN IP instead (e.g., http://192.168.1.100:8989). These ports are exposed on the Gluetun container.

14. Quality Profiles — Recyclarr & TRaSH Guides

The default quality profiles in Sonarr/Radarr are basic. The community has spent years building TRaSH Guides — optimized quality profiles with custom formats that prefer proper releases, avoid bad encoders, and score releases intelligently.

Recyclarr syncs these profiles to your Sonarr/Radarr automatically.

compose.yaml (recyclarr service)
recyclarr:
  image: ghcr.io/recyclarr/recyclarr:latest
  container_name: recyclarr
  restart: unless-stopped
  network_mode: service:gluetun
  user: 1000:1000
  volumes:
    - /srv/docker/recyclarr:/config

Setup

  1. First run generates a config template at /srv/docker/recyclarr/recyclarr.yml
  2. Edit it to connect to Sonarr and Radarr:
sonarr:
  main:
    base_url: http://localhost:8989
    api_key: your-sonarr-api-key
    quality_definition:
      type: series
    quality_profiles:
      - name: WEB-1080p

radarr:
  main:
    base_url: http://localhost:7878
    api_key: your-radarr-api-key
    quality_definition:
      type: movie
    quality_profiles:
      - name: HD Bluray + WEB
  1. Run manually once to verify: docker exec recyclarr recyclarr sync
  2. After that, it runs automatically on a daily schedule
Read the TRaSH Guides first
Visit trash-guides.info before configuring Recyclarr. They explain what each quality profile and custom format does, and which ones to use for your setup. Recyclarr is just the tool that applies them.

15. Extras

These are optional but useful tools that improve the stack.

Autobrr — Real-time release grabbing

Monitors IRC announce channels from your private trackers and grabs releases the instant they're uploaded — before they even appear in RSS feeds. This is how you get the best speeds and build ratio.

autobrr:
  image: ghcr.io/autobrr/autobrr:latest
  container_name: autobrr
  restart: unless-stopped
  network_mode: service:gluetun
  user: 1000:1000
  volumes:
    - /srv/docker/autobrr:/config

Setup: http://your-server:7474 → connect to qBittorrent, add tracker IRC channels, create filters.

qbit_manage — Torrent housekeeping

Automatically tags, categorizes, and cleans up torrents in qBittorrent. Removes unregistered torrents, enforces share ratios, organizes by tracker, maintains a recycle bin.

qbit_manage:
  image: bobokun/qbit_manage:latest
  container_name: qbit_manage
  restart: unless-stopped
  network_mode: service:gluetun
  environment:
    - QBT_RUN=false         # Run as daemon (not one-shot)
    - QBT_SCHEDULE=30       # Run every 30 minutes
  volumes:
    - /srv/docker/qbit_manage:/config
    - /data:/data
QBT_RUN must be false for daemon mode
QBT_RUN=true means "run once and exit", which causes a restart loop with restart: unless-stopped. Set it to false and use QBT_SCHEDULE=30 for the daemon to run every 30 minutes.

Cross-Seed — Find cross-seedable torrents

Scans your existing downloads and finds the same content on other trackers. Seed the same file on multiple trackers without downloading again — free ratio.

cross-seed:
  image: crossseed/cross-seed:latest
  container_name: cross-seed
  restart: unless-stopped
  environment:
    - PUID=1000
    - PGID=1000
    - TZ=Europe/Copenhagen
  volumes:
    - /srv/docker/cross-seed:/config
    - /srv/docker/cross-seed/cross-seeds:/cross-seeds
    - /data:/data
  ports:
    - "2468:2468"

Unpackerr — Extract archived releases

Some trackers upload releases as RAR archives. Unpackerr watches for completed downloads and automatically extracts them so Sonarr/Radarr can import the media files.

unpackerr:
  image: golift/unpackerr:latest
  container_name: unpackerr
  restart: unless-stopped
  environment:
    - PUID=1000
    - PGID=1000
    - TZ=Europe/Copenhagen
  volumes:
    - /srv/docker/unpackerr:/config
    - /data:/data
  security_opt:
    - no-new-privileges:true

16. Connecting Everything Together

Step 1: Verify the VPN

# Check Gluetun is connected
docker exec gluetun curl -sf https://ipinfo.io
# Should show your VPN IP, NOT your real IP

# Check port forwarding
curl -sf http://localhost:8000/v1/openvpn/portforwarded
# Should show {"port":12345}

Step 2: Configure qBittorrent

Set download paths and categories (section 6). Port-sync script handles the forwarded port.

Step 3: Set up Prowlarr

Add indexers, connect to Sonarr/Radarr/Lidarr. Prowlarr auto-syncs indexers to all apps.

Step 4: Set up the *arr apps

For each (Sonarr, Radarr, Lidarr): add root folder, add qBittorrent as download client, note the API key.

Step 5: Set up Bazarr

Connect to Sonarr/Radarr using their API keys. Add subtitle providers.

Step 6: Set up Plex

Add media libraries pointing to /data/media/{movies,tv,music}.

Step 7: Set up Seerr

Connect to Plex, Sonarr, and Radarr.

Step 8: Test the full flow

  1. Open Seerr → request a movie
  2. Check Radarr → should show the movie as "searching"
  3. Check qBittorrent → should show a download starting
  4. Wait for download → Radarr imports → appears in Plex
  5. Check Bazarr → subtitles should appear automatically

17. The Complete Docker Compose

Here's a minimal but complete compose file for the core stack. Copy this, adjust the paths and credentials, and docker compose up -d.

/opt/stacks/mediastack/.env
PUID=1000
PGID=1000
TZ=Europe/Copenhagen
UMASK=022

# VPN credentials
OPENVPN_USER=your-vpn-username
OPENVPN_PASSWORD=your-vpn-password
SERVER_REGIONS=Netherlands
/opt/stacks/mediastack/compose.yaml
services:
  # === VPN GATEWAY ===
  gluetun:
    image: qmcgaw/gluetun:latest
    container_name: gluetun
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
    environment:
      - VPN_SERVICE_PROVIDER=private internet access
      - OPENVPN_USER=${OPENVPN_USER}
      - OPENVPN_PASSWORD=${OPENVPN_PASSWORD}
      - SERVER_REGIONS=${SERVER_REGIONS}
      - PORT_FORWARD_ONLY=true
      - VPN_PORT_FORWARDING=on
      - UPDATER_PERIOD=24h
      - TZ=${TZ}
    volumes:
      - /srv/docker/gluetun:/gluetun
    ports:
      - "8090:8090"   - "8989:8989"   - "7878:7878"
      - "8686:8686"   - "9696:9696"   - "6767:6767"
      - "7474:7474"   - "8191:8191"
    networks:
      vpn: { ipv4_address: 172.21.0.2 }
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/v1/openvpn/status"]
      interval: 30s
      timeout: 10s
      retries: 5

  # === DOWNLOAD CLIENT ===
  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    restart: unless-stopped
    network_mode: service:gluetun
    depends_on: { gluetun: { condition: service_healthy } }
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
      - UMASK=${UMASK}
      - WEBUI_PORT=8090
    volumes:
      - /srv/docker/qbittorrent:/config
      - /data:/data

  # === INDEXER MANAGER ===
  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    restart: unless-stopped
    network_mode: service:gluetun
    depends_on: { gluetun: { condition: service_healthy } }
    environment: [PUID=${PUID}, PGID=${PGID}, TZ=${TZ}]
    volumes: [/srv/docker/prowlarr/config:/config]

  # === TV SHOWS ===
  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    restart: unless-stopped
    network_mode: service:gluetun
    depends_on: { gluetun: { condition: service_healthy } }
    environment: [PUID=${PUID}, PGID=${PGID}, TZ=${TZ}, UMASK=${UMASK}]
    volumes: [sonarr_config:/config, /data:/data]

  # === MOVIES ===
  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    restart: unless-stopped
    network_mode: service:gluetun
    depends_on: { gluetun: { condition: service_healthy } }
    environment: [PUID=${PUID}, PGID=${PGID}, TZ=${TZ}, UMASK=${UMASK}]
    volumes: [radarr_config:/config, /data:/data]

  # === MUSIC ===
  lidarr:
    image: lscr.io/linuxserver/lidarr:latest
    container_name: lidarr
    restart: unless-stopped
    network_mode: service:gluetun
    depends_on: { gluetun: { condition: service_healthy } }
    environment: [PUID=${PUID}, PGID=${PGID}, TZ=${TZ}, UMASK=${UMASK}]
    volumes: [/srv/docker/lidarr/config:/config, /data:/data]

  # === SUBTITLES ===
  bazarr:
    image: lscr.io/linuxserver/bazarr:latest
    container_name: bazarr
    restart: unless-stopped
    network_mode: service:gluetun
    depends_on: { gluetun: { condition: service_healthy } }
    environment: [PUID=${PUID}, PGID=${PGID}, TZ=${TZ}, UMASK=${UMASK}]
    volumes: [/srv/docker/bazarr/config:/config, /data:/data]

  # === CAPTCHA SOLVER ===
  flaresolverr:
    image: ghcr.io/flaresolverr/flaresolverr:latest
    container_name: flaresolverr
    restart: unless-stopped
    network_mode: service:gluetun
    environment: [TZ=${TZ}]

  # === QUALITY PROFILES ===
  recyclarr:
    image: ghcr.io/recyclarr/recyclarr:latest
    container_name: recyclarr
    restart: unless-stopped
    network_mode: service:gluetun
    user: ${PUID}:${PGID}
    volumes: [/srv/docker/recyclarr:/config]

volumes:
  sonarr_config: { driver: local }
  radarr_config: { driver: local }

networks:
  vpn:
    driver: bridge
    ipam:
      config:
        - subnet: 172.21.0.0/16

18. First Boot Walkthrough

Here's the exact order to configure everything after docker compose up -d:

  1. Wait for Gluetun: docker compose logs -f gluetun — wait for "healthy" status
  2. qBittorrent: Open :8090, check logs for temp password (docker logs qbittorrent 2>&1 | grep "temporary password"), change it, set download paths, create categories (tv, movies, music)
  3. Sonarr: Open :8989, set auth, add root folder /data/media/tv, add qBittorrent (localhost:8090, category tv), copy API key
  4. Radarr: Open :7878, same as Sonarr but root folder /data/media/movies, category movies, copy API key
  5. Lidarr: Open :8686, root folder /data/media/music, category music, copy API key
  6. Prowlarr: Open :9696, set auth, add indexers, connect to Sonarr/Radarr/Lidarr via Settings → Apps using localhost URLs + API keys, hit Sync
  7. Bazarr: Open :6767, connect to Sonarr (localhost:8989) and Radarr (localhost:7878) with API keys, add subtitle providers, set languages
  8. Plex: Open :32400/web, sign in, add libraries: /data/media/movies, /data/media/tv, /data/media/music
  9. Seerr: Open :5055, connect to Plex, add Radarr (http://your-LAN-ip:7878) and Sonarr (http://your-LAN-ip:8989) with API keys
  10. Test it: Request a movie in Seerr. Watch it flow: Radarr → qBittorrent → Plex. Check Bazarr for subtitles.

19. Troubleshooting

VPN issues

ProblemFix
Gluetun won't connectCheck VPN credentials. Check docker logs gluetun. Try a different SERVER_REGIONS.
"All connections failed"Server list outdated. Delete /srv/docker/gluetun/servers.json and restart.
qBittorrent can't reach the internetGluetun not healthy. Check docker compose ps.
Real IP showing in qBittorrentnetwork_mode: service:gluetun is missing. Verify: docker exec qbittorrent curl -sf https://ipinfo.io

*Arr app issues

ProblemFix
"Unable to connect to indexer"Prowlarr indexers not synced. Go to Prowlarr → Settings → Apps → Sync.
"Download client unavailable"Use localhost:8090 (not server IP) since both are on network_mode: service:gluetun.
"Import failed: destination exists"Duplicate file. Check if already imported. May need to clear the download queue.
"Database is locked"Config is on NFS. Move to a local Docker volume (see section 8).
Sonarr/Radarr can't find downloadsWrong download path. Both must share the same /data mount.

Plex issues

ProblemFix
Can't find mediaLibrary path wrong. Must match: /data/media/movies, /data/media/tv.
Claim token expiredTokens last 4 minutes. Generate new at plex.tv/claim, update compose, recreate.
Hardware transcoding not workingCheck /dev/dri exists. Add user to video group. Requires Plex Pass.

Hardlinks not working

Files being copied instead of hardlinked (double disk usage)
  1. Verify all containers mount the same top-level path (/data:/data)
  2. Verify downloads and media library are on the same filesystem (df -h /data)
  3. Check with ls -li — hardlinked files have the same inode number
  4. Cross-device hardlinks are impossible. If downloads are on a different disk than your library, use mergerfs or change your layout.

Networking cheat sheet

From → ToURL to useWhy
Sonarr → qBittorrentlocalhost:8090Both on network_mode: service:gluetun
Prowlarr → Sonarrlocalhost:8989Same reason
Bazarr → Sonarrlocalhost:8989Same reason
Seerr → Sonarrhttp://192.168.x.x:8989Seerr is NOT behind VPN — use host IP
Plex → mediaDirect filesystemHost network, reads files directly

Glossary

TermMeaning
HardlinkA second filename pointing to the same data on disk. Two paths, one file, one set of disk space. Only works within the same filesystem.
Seed/SeedingUploading a file to other torrent users after downloading it. Required by most private trackers.
RatioUpload divided by download. Private trackers often require 1.0+ (upload as much as you download).
IndexerA torrent site/tracker that lists available torrents. Prowlarr connects to these.
Custom FormatA scoring rule in Sonarr/Radarr that gives points to releases matching criteria (e.g., +10 for Bluray, -50 for CAM).
Quality ProfileRules defining which quality levels are acceptable and in what order to prefer them.
Port Forwarding (VPN)Your VPN provider assigns you a port that external peers can connect to. Without it, seeding is slow.
TRaSH GuidesCommunity-maintained guides for optimal *arr configuration. The gold standard for quality profiles.
network_mode: service:XDocker feature that makes a container share another container's network. Used to route traffic through Gluetun.