One Docker Compose arr stack for VPS with hardlinks
Run Prowlarr, Sonarr, Radarr and qBittorrent from one Docker Compose file, with shared PUID, PGID and volume paths wey keep hardlinks working.
Wetin you dey build
A 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 the download client. Dem dey communicate with each other through the Compose network by service name, and dem share one folder tree for the host. The installation short. The part wey go decide whether the stack go work for years or disturb you every week na the volume layout, so most of this guide dey focus on that.
The stack no dey find content for you. Prowlarr dey keep any indexers wey you add to am, and na your decision and legal responsibility to choose the indexers wey you use. This guide 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 already dey print something for your server.
Why hardlinks dey break, and why na the whole game be this
When Sonarr finish with a download, e dey import the file enter your library. If the download folder and the library folder dey for the same filesystem, the import na hardlink: another name wey dey point to the same data for disk. E no dey use extra space or time. The torrent continue to seed from the old name while your media server dey read the new one.
If the two folders dey for different filesystems, the kernel no fit create that link. Sonarr go fall back to copy. One 40 GB season go now use 80 GB disk space and several minutes of input and output, and the import log go record say the 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 a container, a bind mount na filesystem boundary. If you mount /mnt/data/torrents as /downloads and /mnt/data/media as /tv, even though both dey for one host disk, Sonarr go see two separate mounts and refuse to link across them. The official LinuxServer.io image documentation talk this directly: using separate /downloads and /tv paths go sacrifice the ability to hardlink.
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, and hardlinks go work.
Create the user, the group, and the folders
The containers dey write files as numeric user id, wey PUID and PGID set. Use your own account so you fit read and edit those files over SSH without sudo.
id -u
id -gBoth usually dey print 1000 for a fresh Ubuntu VPS. Now build the tree. Put am for any 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 the 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 e go put the libraries for /media/Movies and /media/Shows, exactly where that guide put dem.
The environment file
Keep the values wey dey 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 the 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 dey identical for the three containers wey dey touch media. Prowlarr no get am, because Prowlarr no dey ever open media file.
Every web port dey bind to 127.0.0.1, so Docker publish am for loopback address only. Plain 8989:8989 for 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 steady, and why Docker publish ports straight through ufw explain am.
Port 6881 publish 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 on 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.
Reach 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.
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 di paths wey dey inside each application
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. Any download wey finish outside /data no fit enter the library with hardlink.
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. The 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 fit work as hostname because Compose puts all four containers for one network with internal DNS (domain name system) service. No use localhost here: inside the Sonarr container, localhost na Sonarr.
Leave Remote Path Mappings empty. That feature dey translate the path wey the download client reports into a path wey the arr application fit see. With one shared /data mount, both containers already agree on every path. Na the second reason this layout worth the effort.
Connect Prowlarr to Sonarr and Radarr
Prowlarr dey push indexer definitions go the other applications, so you go configure indexer one time instead of two times. E need API (application programming interface) key from each one.
For Sonarr, open Settings, then General, and copy the 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 no gree almost always mean say service name wrong or http:// prefix dey miss. 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 wrong.
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 go show 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 the hardlink fail.
Monitor the disk too. df -h /mnt/data suppose barely change when import happen, because hardlink dey add name but e no add data.
Wetin dey really break
Permission errors during 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 the owner id match your PUID, and remember say directories need execute bit before the container fit enter dem.
Files wey show say root own dem mean say the container start before the 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 the library file disappear, e mean say the import na copy wey dem later remove, or you delete the data instead of the torrent entry. With real hardlink, removing one name no dey affect the other, because system free the data only when the link count reach zero.
If disk dey fill faster than the media wey you add, na the copy problem for im most expensive form. Run the stat check above before you buy more storage.
Wetin dis stack need from a VPS
Di 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. Di load dey come from somewhere else. Download client fit saturate disk input and output for large torrents, and media server wey dey transcode video for di same box go use 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.
FAQ
Sonarr dey copy files instead of hardlinking dem because of wetin?
Na because source and destination dey different filesystems from the container point of view. Two separate bind mounts, like /downloads and /tv, be 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 the link go work. Confirm the result with stat -c '%i %h %n' on both files: the inode go dey the same, and the link count go be 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 a fresh Ubuntu VPS, e usually be 1000 for both. Every container for the stack must use the same pair. Otherwise, one application go 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 the 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 how e look, because Docker dey insert its own firewall rules, and 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 into 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.