The short version: put your downloads and your media library under one shared root (/data), mount that same root into every container, and run only the torrent client behind a VPN container. Get the folder layout right and every import is an instant hardlink; get it wrong and every import is a slow copy that doubles your disk usage. Everything below is one Compose file plus three connections in the web UIs.
Cross-post from Peira Labs. Full version with screenshots at every step: peira.dev/articles/arr-stack-docker-deploy
New to the stack? Start with the concepts: What Is the Arr Stack? Sonarr, Radarr, and Prowlarr Explained.
Lawful use: this automates media you have the rights to, such as your own rips, DRM-free purchases, home video, Linux ISOs, and public-domain or Creative Commons releases. Downloading copyrighted material you haven't paid for is illegal in most places, and it isn't what this guide teaches.
1. Get the folder layout right first
This is the single most important decision in the build, and the one you can't easily fix later. When downloads and media sit on the same filesystem, Radarr and Sonarr import a finished download as a hardlink: a second directory entry pointing at the same data on disk. It appears in your library instantly, costs no extra space, and the original keeps seeding. Split them into separate mounts and the containers see two filesystems, so every import falls back to copy-and-delete.
The TRaSH Guides layout uses a single /data root:
/data
├── torrents # download client writes here
│ ├── movies
│ └── tv
└── media # media server reads here
├── movies
└── tv
Don't mount /downloads and /movies as separate volumes. Mount the parent /data into every app. Create it with the UID/GID you'll give the containers:
sudo mkdir -p /data/torrents/{movies,tv} /data/media/{movies,tv}
sudo chown -R 1000:1000 /data
2. The Compose file
Prowlarr, Radarr, and Sonarr run normally on your LAN. qBittorrent lives inside a gluetun VPN container, so the torrent traffic, and only the torrent traffic, goes through the VPN. The images are from LinuxServer.io, which share one PUID/PGID permissions model.
Make these values your own first: YOUR_WIREGUARD_PRIVATE_KEY (from your VPN provider's WireGuard config; keep it in a secret store), protonvpn and Netherlands (your provider and server country), 192.168.1.0/24 (your real LAN subnet), 1000/1000 (the UID/GID that owns /data; check with id), and the /opt/... config paths.
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
gluetun blocks everything except the VPN by default, so FIREWALL_OUTBOUND_SUBNETS must match your real LAN or the Arr apps can't reach qBittorrent. Bring it up and prove the tunnel works:
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
3. Wire the apps together, in this order
Register Sonarr and Radarr in Prowlarr. Copy each app's API key from its Settings → General page, then add both in Prowlarr → Settings → Apps using the container URLs (http://radarr:7878, http://sonarr:8989). Containers reach each other by service name.
Add indexers in Prowlarr only. They sync to Radarr and Sonarr automatically, so you never maintain two lists.
Add qBittorrent as the download client in Radarr and in Sonarr: Settings → Download Clients → qBittorrent at http://<host-ip>:8080 (gluetun publishes the UI on the host), with a category of radarr or sonarr so downloads land in the right subfolder.
Set each root folder to the media subpath: /data/media/movies in Radarr, /data/media/tv in Sonarr. Because qBittorrent writes to /data/torrents/... under the same mount, imports become hardlinks.
4. Prove it end to end
Add one movie in Radarr, mark it monitored, and hit Search. Radarr queries Prowlarr's indexers, picks a release by its quality settings, hands it to qBittorrent, and imports the finished file into /data/media/movies. Success looks like this: the import completes instantly and your disk usage doesn't jump by the file's size. If either is off, look at the folder layout from step 1 first.
If a download finishes but won't import, it's almost always ownership. Keep the same PUID/PGID across qBittorrent and the Arr apps, and make sure /data belongs to that user.
Go further
- Copy-paste version: the Arr stack Compose playbook
- The download client in depth, with kill-switch and port-forward sync: the VPN torrent stack playbook
- Take it hands-off with import lists, quality profiles, and a media-server rescan: Automating the Arr Stack
Sources: TRaSH Guides: Hardlinks and Instant Moves · Servarr Docker Guide
Written by Peira Labs. Full version, with the screenshots and FAQ, at peira.dev.



Top comments (0)