One Docker Compose File for Your Full Arr Stack
Run Prowlarr, Sonarr, Radarr and qBittorrent for one VPS. Use shared PUID, PGID and volume paths so imports fit use hardlinks without breaking.
O wey you dey build
Docker Compose arr stack na four containers wey dey manage media library: Prowlarr for indexer settings, Sonarr for series, Radarr for films, and qBittorrent as download client. Dem dey communicate with each other through Compose network by service name, and dem share one folder tree for host. The installation short. Na volume layout go decide whether the stack go work for years or dey give you problem every week, so na that one most of this guide dey focus on.
The stack no dey find content for you. Prowlarr dey hold any indexers wey you add to am, and na you go decide which indexers to use, together with your legal responsibility. This guide dey cover the plumbing: users, paths, permissions, container networking, and the checks wey go prove say e dey work.
If you never write Compose file before, read Docker Compose basics for VPS first. This post assume say docker compose version don already print something for your server.
Why hardlinks dey break, and why na the whole game be this
When Sonarr finish with download, e go import the file into your library. If download folder and library folder dey for the same filesystem, the import na hardlink: second name wey dey point to the same data for disk. E no dey use extra space or time. The torrent go continue seeding from the old name while your media server dey read the new one.
If the two folders dey for different filesystems, kernel no fit create that link. Sonarr go fall back to copy. One 40 GB season go now take 80 GB disk space and several minutes of input and output, while import log go record say hardlink fail and the file copy instead. For VPS wey get fixed disk allowance, na so people dey run out of space within one week.
Na here the trap dey. Inside container, bind mount na filesystem boundary. Mount /mnt/data/torrents as /downloads and /mnt/data/media as /tv, and even though both dey on one host disk, Sonarr go see two separate mounts and refuse to link between dem. The official LinuxServer.io image documentation talk this directly: if you use separate /downloads and /tv paths, you lose the ability to create hardlinks.
The fix na one mount. Every container wey dey touch media go get the same single volume, /mnt/data:/data, and every path wey dem use go be folder inside am. One mount point, one filesystem, hardlinks go work.
Create user, group, and folders
Containers dey write files with numeric user id, wey PUID and PGID set. Use your own account so you fit read and edit those files through SSH without sudo.
id -u
id -gBoth usually dey print 1000 for fresh Ubuntu VPS. Now build the tree. Put am for the disk wey dey hold your media, and keep the whole tree for that one disk.
sudo mkdir -p /mnt/data/torrents/movies /mnt/data/torrents/tv
sudo mkdir -p /mnt/data/media/Movies /mnt/data/media/Shows
sudo chown -R 1000:1000 /mnt/data
sudo chmod -R 775 /mnt/dataCheck say na really one filesystem before you continue:
df --output=source,target /mnt/data/torrents /mnt/data/mediaBoth lines must show the same source device. If na two different devices, hardlinks no go ever work, no matter wetin you set for container config.
The library folders get names Movies and Shows on purpose. If you already dey run Jellyfin as your media server, mount /mnt/data/media inside Jellyfin as /media, and the libraries go land for /media/Movies and /media/Shows, exactly where that guide put dem.
Environment file
Keep values wey fit change for each server inside .env, near the Compose file.
mkdir -p ~/arr && cd ~/arrWrite ~/arr/.env:
PUID=1000
PGID=1000
TZ=Etc/UTC
DATA_ROOT=/mnt/dataSet TZ to your own zone, like Europe/Berlin. The arr applications dey schedule tasks and add timestamps to log lines for that zone, so wrong value go make all logs confusing later.
The Compose file
Write ~/arr/docker-compose.yml:
services:
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ./config/prowlarr:/config
ports:
- 127.0.0.1:9696:9696
restart: unless-stopped
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ./config/sonarr:/config
- ${DATA_ROOT}:/data
ports:
- 127.0.0.1:8989:8989
restart: unless-stopped
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ./config/radarr:/config
- ${DATA_ROOT}:/data
ports:
- 127.0.0.1:7878:7878
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- WEBUI_PORT=8080
- TORRENTING_PORT=6881
volumes:
- ./config/qbittorrent:/config
- ${DATA_ROOT}:/data
ports:
- 127.0.0.1:8080:8080
- 6881:6881
- 6881:6881/udp
stop_grace_period: "10s"
restart: unless-stoppedFour things for that file dey do real work.
${DATA_ROOT}:/data na the same for the three containers wey dey handle media. Prowlarr no get am because Prowlarr no dey ever open media file.
Every web port dey bound to 127.0.0.1, so Docker publish am for loopback address only. Plain 8989:8989 go publish am for every interface, and Docker own firewall rules go carry that traffic pass a ufw deny rule directly. This behaviour dey surprise people all the time, and why Docker publishes ports straight through ufw explain am.
Dem publish port 6881 for all interfaces on purpose. Na the torrent listening port be this, and e must dey reachable for incoming peer connections. Allow am with sudo ufw allow 6881, and read the ufw firewall basics for a VPS if that command new to you.
The config directories dey separate for each application, and na only the media volume dem share. Create dem before the first start so dem go belong to your user instead of root:
mkdir -p ~/arr/config/prowlarr ~/arr/config/sonarr ~/arr/config/radarr ~/arr/config/qbittorrent
docker compose up -d
docker compose psAll four services suppose read running. As of July 2026, dem publish these images for lscr.io, and the latest tag dey follow the current stable release. So use version tag instead if you want upgrades to be decision, no be surprise.
Use web interfaces safely
Because the ports dey on loopback, nothing dey exposed yet. Forward dem through SSH from your own machine:
ssh -L 9696:127.0.0.1:9696 -L 8989:127.0.0.1:8989 \
-L 7878:127.0.0.1:7878 -L 8080:127.0.0.1:8080 you@your-serverNow http://127.0.0.1:8989 for your browser go reach Sonarr for the server. For permanent access, put the stack behind Traefik with TLS certificates for several apps, or reach the server through a WireGuard VPN wey you host yourself. None of these applications suppose dey on public internet with only their own login page in front of dem. If you choose reverse proxy and you prefer one account for all four interfaces instead of four separate application logins to manage, Authentik gives you self-hosted single sign-on wey Traefik fit enforce for every request with forward auth.
qBittorrent dey generate random administrator password for first start and print am for the container log. Read am, then change am for the web interface:
docker compose logs qbittorrent | grep -i passwordIf you skip the change, e go generate new random password for every restart, and you go return to the logs every time.
Set paths wey each application go use
For qBittorrent, open Options, then Downloads, and set the default save path to /data/torrents. Keep the incomplete-downloads folder inside the same tree, like /data/torrents/incomplete. If download finish for anywhere outside /data, e no fit become hardlink inside the library.
For Sonarr, open Settings, then Media Management, and add the root folder /data/media/Shows. For Radarr, the root folder na /data/media/Movies. These paths dey inside the container. Host path /mnt/data/media/Shows go fail, because that directory no dey exist from the container side.
For both Sonarr and Radarr, open Settings, then Download Clients, and add qBittorrent. The host na qbittorrent and the port na 8080. The service name dey work as hostname because Compose puts all four containers for one network with internal DNS (domain name system) service. No use localhost here: inside Sonarr container, localhost na Sonarr.
Leave Remote Path Mappings empty. That feature dey translate the path wey download client report into path wey arr application fit see. Since both containers share one /data mount, dem already agree on every path. Na the second reason why this layout worth the work.
Connect Prowlarr to Sonarr and Radarr
Prowlarr dey push indexer definitions go the other applications, so you configure indexer once instead of twice. E need API (application programming interface) key from each one.
For Sonarr, open Settings, then General, and copy API key. For Prowlarr, open Settings, then Apps, add Sonarr application, and fill the three fields. Prowlarr Server na http://prowlarr:9696. Sonarr Server na http://sonarr:8989. API Key na the value wey you copy. Press Test. Green result mean say Prowlarr reach Sonarr through the Compose network. Repeat the same thing with Radarr for http://radarr:7878.
Red result wey talk say connection dey refused almost always mean wrong service name or missing http:// prefix. Confirm say the name dey resolve from inside the container:
docker compose exec prowlarr curl -sS -o /dev/null -w '%{http_code}\n' http://sonarr:8989HTTP status code prove say network path dey okay. Name resolution error prove say service name no correct.
Prove say hardlinks dey happen
No trust the setup until you don see the link count. After dem don import one item, compare the downloaded file with the library file:
stat -c '%i %h %n' /mnt/data/torrents/tv/*/*.mkv
stat -c '%i %h %n' /mnt/data/media/Shows/*/*/*.mkvThe first number na the inode, and the second one na the link count. File wey dem hardlink get the same inode for both places and link count of 2. Two different inodes, each with link count of 1, mean say Sonarr copy the file, and the import log go talk say hardlink fail.
Watch the disk too. df -h /mnt/data suppose barely move when import happen, because hardlink dey add name but e no add data.
Wetin actually dey spoil
Permission errors for import mean say the container user id no fit write inside the library folder. The message na Access to the path ... is denied. Use ls -ln /mnt/data/media check whether owner id match your PUID, and remember say directories need execute bit before container fit enter dem.
Files wey show say root own dem mean say container start before host directory exist, so Docker create am as root. Stop the stack, chown the directory, then start am again.
If you delete torrent from qBittorrent and library file disappear, e mean say import na copy wey later get removed, or you delete the data instead of the torrent entry. With real hardlink, removing one name leave the other one intact, because system only free the data when link count reach zero.
If disk dey fill faster than the media wey you add, na copy problem for e most expensive form. Run the stat check wey dey above before you buy more storage.
Wetins this stack need from a VPS
The three arr applications no heavy. Dem dey poll indexers, write to small SQLite database, and rename files. Server wey get 2 GB RAM fit run all four containers comfortably. Na other things dey bring the load. Download client fit fill disk input and output when e dey handle large torrents, and media server wey dey transcode video for the same box go use the CPU. Keep media for volume wey get real throughput, and set bandwidth limit for download client if server dey do any other work wey matter to you. Plan those other workloads separately instead make you assume say headroom dey: self-hosted AFFiNE workspace na another four containers with database behind dem, and for 2 GB box e go need most of the memory for itself. No be every extra service dey cost that much: something wey get one main purpose like self-hosted openGym workout tracker fit share the box well, as long as you give am its own TLS and know where its database file dey before you trust am with one year training history. Anything wey carry web application, Postgres database, and background worker queue dey closer to the AFFiNE end of that range, so decide whether self-hosted Chatwoot support desk belong for this server or e need its own server before you discover the limit halfway through an import. Workloads wey dey come in bursts need even more caution, because na their peak, no be their average, dey collide with import: if you dey consider self-hosted OneCLI wey dey give each person their own sandboxed agent, check its published sizing numbers against wetin really free while qBittorrent dey run flat out, no be against wetin free -h dey show for idle box.
FAQ
Why Sonarr dey copy files instead of hardlinking dem?
Because the source and destination dey for different filesystems from the container point of view. Two separate bind mounts, like /downloads and /tv, na two filesystems even when both come from one host disk. Mount one parent directory as /data for every container, put downloads and library inside am, and link go become possible. Confirm the result with stat -c '%i %h %n' for both files: the same inode and a link count of 2.
Which PUID and PGID I suppose use?
Use the numeric id of the host account wey own the media tree. You fit get am with id -u and id -g. For fresh Ubuntu VPS, e usually be 1000 for both. Every container for the stack must use the same pair. Otherwise, one application fit write files wey another one no fit modify. After you change the values, recreate the containers with docker compose up -d --force-recreate and fix the existing files with chown -R.
I need expose these web interfaces to internet?
No, and you no suppose do am. Bind each published port to 127.0.0.1 inside the Compose file. Then reach the interfaces through an SSH tunnel, a VPN, or a reverse proxy wey terminate TLS (transport layer security) and add its own authentication. Publishing dem directly worse pass as e look, because Docker dey insert its own firewall rules. A ufw deny rule no go stop that traffic.
Where I fit find the qBittorrent password?
The LinuxServer.io image dey print temporary password for the admin user inside its startup log. Run docker compose logs qbittorrent | grep -i password to read am. Then set permanent password under Options and Web UI. E go generate new temporary password every time e restart until you set your own.
Jellyfin fit use the same folders?
Yes, and na the reason for this layout. Mount /mnt/data/media inside your media server as /media. Its libraries go dey at /media/Movies and /media/Shows, while Sonarr and Radarr dey write to those same directories through /data/media. Give the media server the same PUID and PGID so e fit read wetin the arr stack write.