SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

The arr stack in one Docker Compose file

Run Prowlarr, Sonarr, Radarr and qBittorrent from one Docker Compose file on a VPS, with the shared PUID, PGID and volume layout that keeps hardlinks working.

What you are building

A Docker Compose arr stack is four containers that manage a media library: Prowlarr for indexer settings, Sonarr for series, Radarr for films, and qBittorrent as the download client. They talk to each other over the Compose network by service name, and they share one folder tree on the host. The install is short. The part that decides whether the stack works for years or fights you every week is the volume layout, so most of this guide is about that.

The stack does not find content for you. Prowlarr holds whatever indexers you add to it, and which indexers you use is your decision and your legal responsibility. This guide covers the plumbing: users, paths, permissions, container networking, and the checks that prove it is working.

If you have never written a Compose file, read the Docker Compose basics for a VPS first. This post assumes docker compose version already prints something on your server.

When Sonarr finishes with a download, it imports the file into your library. If the download folder and the library folder sit on the same filesystem, the import is a hardlink: a second name pointing at the same data on disk. It takes no extra space and no time. The torrent keeps seeding from the old name while your media server reads the new one.

If the two folders are on different filesystems, the kernel cannot make that link. Sonarr falls back to a copy. A 40 GB season now takes 80 GB of disk and several minutes of input and output, and the import log records that the hardlink failed and the file was copied instead. On a VPS with a fixed disk allowance, that is how people run out of space in a week.

Here is the trap. Inside a container, a bind mount is a filesystem boundary. Mount /mnt/data/torrents as /downloads and /mnt/data/media as /tv, and even though both live on one host disk, Sonarr sees two separate mounts and refuses to link across them. The official LinuxServer.io image documentation says this directly: using the separate /downloads and /tv paths sacrifices the ability to hardlink.

The fix is one mount. Every container that touches media gets the same single volume, /mnt/data:/data, and every path they use is a folder inside it. One mount point, one filesystem, working hardlinks.

Create the user, the group, and the folders

The containers write files as a numeric user id, set by PUID and PGID. Use your own account so you can read and edit those files over SSH without sudo.

id -u
id -g

Both usually print 1000 on a fresh Ubuntu VPS. Now build the tree. Put it on whatever disk holds your media, and keep the whole tree on 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/data

Check that it really is one filesystem before you go further:

df --output=source,target /mnt/data/torrents /mnt/data/media

Both lines must show the same source device. Two different devices means hardlinks will never work, whatever you set in the container config.

The library folders are named Movies and Shows on purpose. If you already run Jellyfin as your media server, mount /mnt/data/media into Jellyfin as /media and its libraries land at /media/Movies and /media/Shows, exactly where that guide puts them.

The environment file

Keep the values that change per server in .env, next to the Compose file.

mkdir -p ~/arr && cd ~/arr

Write ~/arr/.env:

PUID=1000
PGID=1000
TZ=Etc/UTC
DATA_ROOT=/mnt/data

Set TZ to your own zone, such as Europe/Berlin. The arr applications schedule tasks and stamp log lines in that zone, so a wrong value makes every log 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-stopped

Four things in that file are doing real work.

${DATA_ROOT}:/data is identical in the three containers that touch media. Prowlarr does not get it, because Prowlarr never opens a media file.

Every web port is bound to 127.0.0.1, so Docker publishes it on the loopback address only. A plain 8989:8989 would publish it on every interface, and Docker's own firewall rules would carry that traffic straight past a ufw deny rule. That behaviour surprises people constantly, and it is explained in why Docker publishes ports straight through ufw.

Port 6881 is published on all interfaces on purpose. That is the torrent listening port, and it has to be reachable for incoming peer connections. Allow it with sudo ufw allow 6881, and read the ufw firewall basics for a VPS if that command is new.

The config directories are separate per application, and only the media volume is shared. Create them before the first start so they are owned by your user rather than by root:

mkdir -p ~/arr/config/prowlarr ~/arr/config/sonarr ~/arr/config/radarr ~/arr/config/qbittorrent
docker compose up -d
docker compose ps

All four services should read running. As of July 2026 these images are published on lscr.io and the latest tag follows the current stable release, so pin a version tag instead if you want upgrades to be a decision rather than a surprise.

Reach the web interfaces safely

Because the ports are on loopback, nothing is exposed yet. Forward them 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-server

Now http://127.0.0.1:8989 in your browser reaches Sonarr on the server. For permanent access, put the stack behind Traefik with TLS certificates for several apps, or reach the server over a WireGuard VPN you host yourself. None of these applications should sit on the public internet with only their own login page in front of them.

qBittorrent generates a random administrator password on first start and prints it to the container log. Read it, then change it in the web interface:

docker compose logs qbittorrent | grep -i password

If you skip the change, a new random password is generated at every restart, and you will be back in the logs each time.

Set the paths inside each application

In qBittorrent, open Options, then Downloads, and set the default save path to /data/torrents. Keep the incomplete-downloads folder inside the same tree, such as /data/torrents/incomplete. A download that finishes anywhere outside /data cannot be hardlinked into the library.

In Sonarr, open Settings, then Media Management, and add the root folder /data/media/Shows. In Radarr the root folder is /data/media/Movies. These are paths inside the container. The host path /mnt/data/media/Shows is rejected, because that directory does not exist from the container's point of view.

In both Sonarr and Radarr, open Settings, then Download Clients, and add qBittorrent. The host is qbittorrent and the port is 8080. The service name works as a hostname because Compose puts all four containers on one network with an internal DNS (domain name system) service. Do not use localhost here: inside the Sonarr container, localhost is Sonarr.

Leave Remote Path Mappings empty. That feature exists to translate a path the download client reports into a path the arr application can see. With one shared /data mount, both containers already agree on every path, which is the second reason this layout is worth the effort.

Connect Prowlarr to Sonarr and Radarr

Prowlarr pushes indexer definitions into the other applications, so you configure an indexer once instead of twice. It needs an API (application programming interface) key from each.

In Sonarr, open Settings, then General, and copy the API key. In Prowlarr, open Settings, then Apps, add a Sonarr application, and fill in three fields. Prowlarr Server is http://prowlarr:9696. Sonarr Server is http://sonarr:8989. API Key is the value you copied. Press Test. A green result means Prowlarr reached Sonarr over the Compose network. Repeat with Radarr at http://radarr:7878.

A red result saying the connection was refused almost always means a wrong service name or a missing http:// prefix. Confirm the name resolves from inside the container:

docker compose exec prowlarr curl -sS -o /dev/null -w '%{http_code}\n' http://sonarr:8989

An HTTP status code proves the network path is fine. A name resolution error proves the service name is wrong.

Do not trust the setup until you have seen the link count. After one item has been imported, 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/*/*/*.mkv

The first number is the inode and the second is the link count. A file that was hardlinked shows the same inode in both places and a link count of 2. Two different inodes, each with a link count of 1, means Sonarr copied the file, and the import log will say the hardlink failed.

Watch the disk as well. df -h /mnt/data should barely move when an import happens, because a hardlink adds a name and no data.

What actually breaks

Permission errors on import mean the container's user id cannot write into the library folder. The message is Access to the path ... is denied. Check with ls -ln /mnt/data/media that the owner id matches your PUID, and remember that directories need the execute bit before the container can enter them.

Files that appear owned by root mean the container started before the host directory existed, so Docker created it as root. Stop the stack, chown the directory, and start it again.

Deleting a torrent from qBittorrent and finding the library file gone means the import was a copy that was later removed, or you deleted the data rather than the torrent entry. With a real hardlink, removing one name leaves the other intact, because the data is freed only when the link count reaches zero.

A disk filling faster than the media you added is the copy problem in its most expensive form. Run the stat check above before you buy more storage.

What this stack needs from a VPS

The three arr applications are light. They poll indexers, write to a small SQLite database, and rename files. A server with 2 GB of RAM runs all four containers comfortably. The load comes from elsewhere. A download client saturates disk input and output on large torrents, and a media server transcoding video on the same box will take the CPU. Keep media on a volume with real throughput, and set a bandwidth limit on the download client if the server is doing anything else you care about.

FAQ

Why does Sonarr copy files instead of hardlinking them?

Because the source and the destination are on different filesystems from the container's point of view. Two separate bind mounts, such as /downloads and /tv, are two filesystems even when both come from one host disk. Mount a single parent directory as /data in every container, put downloads and library inside it, and the link becomes possible. Confirm the result with stat -c '%i %h %n' on both files: the same inode and a link count of 2.

What PUID and PGID should I use?

Use the numeric id of the host account that owns the media tree, which you get from id -u and id -g. On a fresh Ubuntu VPS that is usually 1000 for both. Every container in the stack must use the same pair, otherwise one application writes files another cannot modify. After changing the values, recreate the containers with docker compose up -d --force-recreate and fix the existing files with chown -R.

Do I need to expose these web interfaces to the internet?

No, and you should not. Bind each published port to 127.0.0.1 in the Compose file, then reach the interfaces through an SSH tunnel, a VPN, or a reverse proxy that terminates TLS (transport layer security) and adds its own authentication. Publishing them directly is worse than it looks, because Docker inserts its own firewall rules and a ufw deny rule will not stop that traffic.

Where do I find the qBittorrent password?

The LinuxServer.io image prints a temporary password for the admin user in its startup log. Run docker compose logs qbittorrent | grep -i password to read it, then set a permanent password under Options and Web UI. A new temporary password is generated on every restart until you set your own.

Can Jellyfin use the same folders?

Yes, and that is the point of the layout. Mount /mnt/data/media into your media server as /media, and its libraries sit at /media/Movies and /media/Shows while Sonarr and Radarr write to those same directories through /data/media. Give the media server the same PUID and PGID so it can read what the arr stack writes.

#sonarr#radarr#prowlarr#docker-compose#self-hosting