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.
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 the same 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 go 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 one ufw deny rule. This behaviour dey surprise people all the time, and why Docker publishes ports straight through ufw explain am.
Port 6881 dey publish for all interfaces on purpose. Na the torrent listening port be that, and incoming peer connections need reach am. The same Docker behaviour dey apply here, so you no need ufw rule for am, and adding one no go change anything. Docker documentation, wey dem check for October 2026, talk say traffic to published port dey route before e reach the chains wey ufw dey use, so sudo ufw allow 6881 no get effect for this port. E go dey reachable from internet as soon as the container start, and ufw deny no go close am too. If you ever need limit who fit connect to am, Docker documentation point to rules for its DOCKER-USER chain instead of ufw. Keep ufw for services wey no dey run inside Docker, like SSH, and read ufw firewall basics for VPS if that tool 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 your user own dem 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 October 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 deliberate decision, not surprise.
Reach web interfaces safely
Because the ports dey on loopback, nothing don expose yet. Forward dem over 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 dey 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. But VPN no fit reach these ports by itself. Docker documentation, wey dem check for October 2026, talk say when you publish port for localhost address, na only Docker host fit access am. To use the VPN, either continue to run the SSH tunnel through am, or change the bindings for the Compose file from 127.0.0.1 to the server own address for the VPN interface. None of these applications suppose dey for public internet with only their own login page protecting dem. If you choose reverse proxy and you prefer one account for all four interfaces instead of tracking four separate application logins, 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 no change am, e go generate new random password every time e restart, and you go need check the logs again each 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.
Wetín 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 of RAM fit run all four containers comfortably. The load dey come from another side. Download client fit saturate disk input and output when e dey handle big 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 the download client if the server dey do any other thing wey matter to you. Plan money for those other things separately instead make you assume say spare capacity dey: self-hosted AFFiNE workspace na another four containers with database behind dem, and for 2 GB box e want most of the memory for itself. No be every extra service cost that much: something single-purpose like self-hosted openGym workout tracker fit share the box well, as long as you give am e own TLS and know where e 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 side of that range, so decide whether self-hosted Chatwoot support desk belong for this server or e own server before you discover the limit halfway through an import. Bursty workloads 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 e own sandboxed agent, check the sizing numbers wey dem publish against wetin actually free while qBittorrent dey run flat out, no be against wetin free -h show for idle box.
FAQ
Why Sonarr dey copy files instead of hardlinking dem?
Na because source and destination dey for different filesystems from the container point of view. Two separate bind mounts, like /downloads and /tv, count as 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, then the link go work. Confirm the result with stat -c '%i %h %n' on both files: the same inode and 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 go get am from id -u and id -g. For fresh Ubuntu VPS, na usually 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 for the Compose file. Then reach the interfaces through SSH tunnel or reverse proxy for the server. The reverse proxy go terminate TLS (transport layer security) and add its own authentication. VPN alone no fit reach port wey bind to 127.0.0.1, because Docker make am reachable only from the host itself. If you want direct access through VPN, publish the ports on the server VPN address instead. Publishing dem directly on public interface worse pass as e look, because Docker dey insert its own firewall rules and 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 main 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.