Deploy the Arr Stack with Docker Compose (Prowlarr, Radarr, Sonarr)

Stand up the full *arr stack in Docker Compose — Prowlarr, Radarr, Sonarr, and a VPN-shielded download client — with the single-root folder layout that enables instant hardlink imports.

On this page
  1. Task 1: Get the folder layout right first
  2. Task 2: Write the Compose file
  3. Task 3: Wire the apps together
  4. Task 4: Test the pipeline
  5. What’s next

If you’ve read what the Arr stack is and you’re ready to actually build it, this is the guide. The stack is a handful of containers that are individually simple but only shine when they’re wired together correctly — and most of the frustration people hit comes from getting the wiring wrong, not the apps themselves. We’ll stand up the whole thing in one Docker Compose file — Prowlarr, Radarr, Sonarr, and a VPN-shielded qBittorrent — and, just as importantly, use the folder layout that makes imports instant instead of slow. That layout decision is the part most tutorials skip, and it’s the one you can’t easily fix later.

You can run this wherever Docker lives in your lab: a NAS, a Docker-on-Proxmox LXC or VM, or a dedicated Linux box. Once it’s up, the automation guide takes over and connects it to your media server.


Task 1: Get the folder layout right first

Before writing a single line of Compose, sort out where files will live — this is the single most important decision in the whole build, and the most common mistake. The rule: downloads and your media library must live under one shared root, mounted identically into every container. Here’s why it matters. When both sit on the same filesystem, Radarr and Sonarr can import a finished download as an instant hardlink — a second directory entry pointing at the same data on disk. The file appears in your library immediately, costs no extra disk space, and the original stays in place for seeding. When downloads and media are split into separate mounts, the containers see two different filesystems, hardlinks become impossible, and every import falls back to a slow copy that temporarily doubles your disk usage.

The TRaSH Guides recommended layout uses a single /data root:

Single-root layout on the host

/data
├── torrents          # download client writes here
│   ├── movies
│   └── tv
└── media             # media server reads here
  ├── movies
  └── tv

Every container mounts the parent /data, not the subfolders:

The mistake that breaks hardlinks

Do NOT mount /downloads and /movies as separate volumes. To the container that’s two filesystems, so imports fall back to copy-and-delete. Mount one shared parent (/data) into every app. Hardlinks and atomic moves require everything on one filesystem — and every app needs read access, while the importing apps need write access.

Create it with correct ownership (use the same UID/GID you’ll give the containers):

Create the data tree

sudo mkdir -p /data/torrents/{movies,tv} /data/media/{movies,tv}
sudo chown -R 1000:1000 /data

Task 2: Write the Compose file

With the folders in place, the Compose file below runs Prowlarr, Radarr, and Sonarr normally on your LAN, while qBittorrent lives inside a gluetun VPN container — so the torrent traffic, and only the torrent traffic, is shielded behind the VPN. The images come from LinuxServer.io, whose containers are the community standard for the Arr apps and share a consistent PUID/PGID permissions model (those two variables tell the container which user to run as, which is how we keep file ownership consistent across the stack). The full file is also available as a copy-paste Arr stack Compose playbook.

First: make these values your own

This file contains placeholders you must replace before it will work: YOUR_WIREGUARD_PRIVATE_KEY (from your VPN provider’s WireGuard config — keep it in a secret store, never in a note), protonvpn and Netherlands (your VPN provider and preferred server country), 192.168.1.0/24 (your actual LAN subnet), 1000/1000 (the UID/GID that owns /data on your host — check with id), and the /opt/... config paths if you keep container config elsewhere. Rule of thumb: if a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.

docker-compose.yml

services:
gluetun:
  image: qmcgaw/gluetun:latest
  container_name: gluetun
  cap_add: [NET_ADMIN]
  devices: [/dev/net/tun:/dev/net/tun]
  ports:
    - "8080:8080"        # qBittorrent WebUI, published by gluetun
  environment:
    - VPN_SERVICE_PROVIDER=protonvpn
    - VPN_TYPE=wireguard
    - WIREGUARD_PRIVATE_KEY=YOUR_WIREGUARD_PRIVATE_KEY
    - SERVER_COUNTRIES=Netherlands
    - VPN_PORT_FORWARDING=on
    - FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24
  restart: unless-stopped

qbittorrent:
  image: lscr.io/linuxserver/qbittorrent:latest
  container_name: qbittorrent
  network_mode: "service:gluetun"      # all traffic through the VPN
  environment: [PUID=1000, PGID=1000, TZ=Etc/UTC, WEBUI_PORT=8080]
  volumes:
    - /opt/qbittorrent:/config
    - /data/torrents:/data/torrents     # same path as the Arr apps
  depends_on: [gluetun]
  restart: unless-stopped

prowlarr:
  image: lscr.io/linuxserver/prowlarr:latest
  container_name: prowlarr
  ports: ["9696:9696"]
  environment: [PUID=1000, PGID=1000, TZ=Etc/UTC]
  volumes: [/opt/prowlarr:/config]
  restart: unless-stopped

radarr:
  image: lscr.io/linuxserver/radarr:latest
  container_name: radarr
  ports: ["7878:7878"]
  environment: [PUID=1000, PGID=1000, TZ=Etc/UTC]
  volumes:
    - /opt/radarr:/config
    - /data:/data                        # ONE shared root -> hardlinks
  restart: unless-stopped

sonarr:
  image: lscr.io/linuxserver/sonarr:latest
  container_name: sonarr
  ports: ["8989:8989"]
  environment: [PUID=1000, PGID=1000, TZ=Etc/UTC]
  volumes:
    - /opt/sonarr:/config
    - /data:/data
  restart: unless-stopped
Set FIREWALL_OUTBOUND_SUBNETS to your real LAN

gluetun blocks everything except the VPN by default. qBittorrent needs to talk to Radarr/Sonarr on your LAN, so FIREWALL_OUTBOUND_SUBNETS must match your actual subnet or the Arr apps can’t reach the client. Full details and port-forward sync are in the VPN Torrent Stack playbook.

Bring it up and confirm the VPN is actually shielding traffic:

Deploy and verify the tunnel

sudo docker compose up -d

# This MUST print a VPN IP, not your home IP:
sudo docker exec gluetun wget -qO- https://api.ipify.org ; echo

Task 3: Wire the apps together

At this point the containers are all running, but they’re strangers to each other — Prowlarr doesn’t know Radarr exists, and nothing knows where downloads go. There are three connections to make, all done in the web UIs, and the order below matters: apps first, then indexers, then the download client.

1Register Sonarr and Radarr in Prowlarr5 min

Grab each app’s API key from its Settings → General page. In Prowlarr → Settings → Apps → Add, add Radarr and Sonarr, pasting each API key and the container URL (http://radarr:7878, http://sonarr:8989 — containers reach each other by service name). Prowlarr will now sync indexers to both.

2Add indexers in Prowlarr5 min

In Prowlarr → Indexers → Add Indexer, add the trackers or Usenet indexers you use. They sync to Radarr and Sonarr automatically — you never touch indexer settings in those apps.

3Add qBittorrent as the download client5 min

In Radarr → Settings → Download Clients → Add → qBittorrent (and again in Sonarr), point it at the qBittorrent host and port (http://<host-ip>:8080, since qBittorrent’s UI is published by gluetun on the host). Set a categoryradarr in Radarr, sonarr in Sonarr — so downloads land in the right subfolder.

Tip

Set each app’s root folder to the media subpath — /data/media/movies in Radarr, /data/media/tv in Sonarr. Because the download client writes to /data/torrents/... under the same /data mount, imports become instant hardlinks exactly as intended.


Task 4: Test the pipeline

Don’t just assume the wiring works — prove it with one end-to-end run. Add a single movie in Radarr, mark it monitored, and hit Search. Then watch the whole chain fire: Radarr queries Prowlarr’s indexers, picks a release according to its quality settings, hands it to qBittorrent, and on completion imports it into /data/media/movies as a hardlink. The tell-tale signs of success are that the import completes instantly and your disk usage doesn’t jump by the file’s size. If either of those is off, the folder layout from Task 1 is the first place to look.

Ownership gotcha

If a download completes but Radarr/Sonarr won’t import it, it’s almost always permissions — the file is owned by a user the app can’t read or move. Keep the same PUID/PGID (1000 here) across qBittorrent and the Arr apps, and make sure /data is owned by that user.


What’s next

You have a working stack: add a title, it downloads and files itself. The automation guide takes it hands-off — import lists that add titles for you, quality profiles that pick the best release, and a notification that rescans your media server the moment a file lands.


Related posts:

Sources: TRaSH Guides — Hardlinks and Instant Moves, Servarr Docker Guide.

Comments

Comments are powered by GitHub Discussions — sign in with a GitHub account to join the conversation.