SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Arr stack folder structure and hardlinks

Why Sonarr and Radarr copy instead of hardlink, the single /data layout that fixes it, the compose volumes to match, and how to prove an import linked.

Why Sonarr copies instead of hardlinking

An arr stack folder structure that supports hardlinks has one rule: every container sees the same /data tree, with torrents, usenet and media beneath it, through a single bind mount (a host directory made visible inside the container at a path you choose). Give Sonarr two mounts instead, /downloads and /media, and the kernel inside the container treats them as two separate devices. The hardlink fails, and Sonarr falls back to copying the whole file. The disk fills twice as fast, and nothing in the web UI says why.

This is the failure that sends readers of the arr stack compose file to the PUID and PGID explainer, and it is usually not a permission problem. The IDs can be correct and the import still copies, because the volume layout is wrong. Below is the layout that works, the compose volumes that match it, the paths to enter in each app including Jellyfin, and the stat check that proves an import made a hardlink rather than a copy.

A file on a Linux filesystem is an inode: the record that owns the data blocks. A file name is a directory entry that points at an inode. A hardlink is a second directory entry pointing at the same inode. There is no original and no copy. Both names are equal and the data exists once on disk. The inode keeps a link count of how many names point at it. Deleting one name lowers the count by one. The data is freed only when the count reaches zero.

That is exactly what the arr apps need. qBittorrent must keep its file in /data/torrents/tv/ to seed it. Sonarr wants the same episode under /data/media/tv/ with a clean name that Jellyfin understands. A hardlink gives both, with one copy of the data, and it completes in a fraction of a millisecond because no data moves. When qBittorrent finishes seeding and deletes its file, the media name stays and the link count goes back down by one.

The limit: a directory entry can only point at an inode on its own filesystem, and Linux goes one step further. The link() system call refuses when the two paths sit on different mounts, even two mounts of the same disk, and returns the error EXDEV. The C library prints that error as Invalid cross-device link on glibc, or Cross-device link on musl, which is what an Alpine-based image uses. The same rule applies to rename(), which is what an atomic move is. Inside one mount a move is a rename: instant, and no extra space. Across mounts it turns into a copy followed by a delete.

Each line under volumes: in a compose file becomes one mount inside the container. This is the layout that breaks (the LinuxServer.io examples use /tv and /movies instead of /media, which is the same mistake):

    volumes:
      - /srv/data/torrents:/downloads
      - /srv/data/media:/media

On the host, /srv/data/torrents and /srv/data/media share one filesystem, and a hardlink between them works. Inside the container, /downloads and /media are two mounts. Sonarr calls link() from /downloads/tv/... to /media/tv/..., the kernel answers EXDEV, and Sonarr does what its code says to do next: copy. It records the failure only at trace level, so the default log never shows it. Set the log level to Trace under Settings, General, Logging, and /config/logs/sonarr.trace.txt (on the host, /srv/appdata/sonarr/logs/sonarr.trace.txt) gains this line on the next import:

Hardlink '/downloads/tv/Example.Show.S01E01.1080p.WEB.H264-GROUP/Example.Show.S01E01.1080p.WEB.H264-GROUP.mkv' to '/media/tv/Example Show/Season 01/Example Show - S01E01 - Pilot.mkv' failed due to cross-device access.

You do not have to wait for an import to see the problem. Ask the container which mount each path lives on:

docker compose exec sonarr df /downloads /media

Two different values in the Mounted on column mean two mounts, and no hardlink can cross them. The next sections make it one mount.

The arr stack folder structure: one /data tree

This is the layout the TRaSH guides recommend, and it is the one the arr stack compose file is built around. One host directory holds everything:

/srv/data
  torrents/
    movies/
    tv/
  usenet/
    incomplete/
    complete/
      movies/
      tv/
  media/
    movies/
    tv/

torrents is the save path of the torrent client, with one subfolder per category. usenet holds the usenet client's incomplete and complete folders, both under one parent so that its own final move is a rename. media is the library: the arr apps import into it and Jellyfin reads from it. Add books or music at each level if you run Readarr or Lidarr.

Create it and give it to the user id your containers run as, then confirm that the two ends of a future hardlink share a filesystem:

sudo mkdir -p /srv/data/torrents/{movies,tv} \
  /srv/data/usenet/incomplete /srv/data/usenet/complete/{movies,tv} \
  /srv/data/media/{movies,tv}
sudo chown -R 1000:1000 /srv/data
df /srv/data/torrents /srv/data/media

Both lines of the df output must show the same Filesystem and the same Mounted on. If media sits on a second disk mounted at /srv/data/media, they differ, and every import will copy no matter what the compose file says, because a hardlink cannot join two filesystems. On a plan with a large separate volume, put the entire data tree on that volume, which is the layout a media server on a storage VPS uses.

The compose volumes that match

These are the four services that touch /data, on the LinuxServer.io images. Everything else, including ports and networks, is unchanged from your existing file, and so are the image tags if you pinned them. Only the volumes: lines matter here.

services:
  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Etc/UTC
    volumes:
      - /srv/appdata/sonarr:/config
      - /srv/data:/data
    restart: unless-stopped

  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Etc/UTC
    volumes:
      - /srv/appdata/radarr:/config
      - /srv/data:/data
    restart: unless-stopped

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Etc/UTC
      - WEBUI_PORT=8080
      - TORRENTING_PORT=6881
    volumes:
      - /srv/appdata/qbittorrent:/config
      - /srv/data/torrents:/data/torrents
    restart: unless-stopped

  jellyfin:
    image: lscr.io/linuxserver/jellyfin:latest
    container_name: jellyfin
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Etc/UTC
    volumes:
      - /srv/appdata/jellyfin:/config
      - /srv/data/media:/data/media
    restart: unless-stopped

Sonarr and Radarr get the whole tree as one mount, because they are the processes that call link(), and both ends of the link must be inside that one mount. qBittorrent gets only /data/torrents, but at the same container path that the folder has inside Sonarr. It never needs the library, and the path it reports for a finished download, /data/torrents/tv/..., is then a string Sonarr can open unchanged. Jellyfin gets only /data/media. Prowlarr needs no /data mount at all. If you run qBittorrent behind gluetun with network_mode: service:gluetun, nothing here changes: that setting shares the network namespace only, and the volumes: lines still belong to the qBittorrent container.

These are bind mounts on purpose. A single named volume would also be one mount, but it would put terabytes of media under Docker's own data directory, and you need the host path for backups and for every other program that reads the library. The trade-offs are in bind mounts versus named volumes. Apply the change with docker compose up -d, which recreates only the services whose definition changed. If you are converting a running stack, move the host folders first, then update the root folder paths in Sonarr and Radarr, and a rescan finds the existing files.

Then repeat the mount check inside the new container:

docker compose exec sonarr df /data/torrents /data/media

Both lines now show /data under Mounted on. One mount, so a link between them is allowed.

Where each app points inside /data

Every path below is a container path. Because the mounts above are consistent, the same string means the same file in every container.

qBittorrent. Options, Downloads: set Default Torrent Management Mode to Automatic and Default Save Path to /data/torrents. With automatic management, a torrent with the category tv is saved under /data/torrents/tv, and one with movies under /data/torrents/movies.

Sonarr. Settings, Download Clients, qBittorrent: Category tv. Settings, Media Management, Root Folders: /data/media/tv. Settings, Media Management, Importing: leave "Use Hard links instead of Copy" enabled. It is on by default, and it only changes what happens when a hardlink is possible. Sonarr chooses between two operations at import time. When the download client reports the item as still seeding, Sonarr copies, and with this setting on that copy becomes a hardlink. When the item may be moved, which is always for usenet and for torrents once the client marks them finished, Sonarr moves it, and inside one mount that move is a rename.

Radarr. The same, with the category movies and the root folder /data/media/movies.

SABnzbd or NZBGet. Mount /srv/data/usenet:/data/usenet, set the incomplete folder to /data/usenet/incomplete and the complete folder to /data/usenet/complete, and let the categories create tv and movies beneath it. Nothing is seeded, so nothing is hardlinked. Sonarr moves the finished file, and because /data/usenet/complete/tv and /data/media/tv are inside one mount, that move is a rename and finishes instantly.

Jellyfin library paths

Jellyfin only reads, so it gets the narrowest mount: /srv/data/media:/data/media. In the Jellyfin dashboard, add a library of the type Shows with the folder /data/media/tv, and one of the type Movies with /data/media/movies. Never add /data/torrents as a library folder. It contains half-written files that Jellyfin would try to probe, and the release-group folder names are not a structure the scanner can read. The rest of the Jellyfin setup, including the reverse proxy and the client apps, is in Jellyfin on a VPS.

Append :ro to the Jellyfin volume if you want a media server that cannot alter the library. Two things stop working in that case: saving artwork and NFO files (the metadata sidecar files Jellyfin can write next to each video) into the media folders, which is off by default, and deleting files from the Jellyfin UI.

Add one episode and wait for Sonarr's Activity page to show it imported. Then, on the host, ask the filesystem what happened:

stat "/srv/data/media/tv/Example Show/Season 01/Example Show - S01E01 - Pilot.mkv"
  File: /srv/data/media/tv/Example Show/Season 01/Example Show - S01E01 - Pilot.mkv
  Size: 1523456789      Blocks: 2975504    IO Block: 4096   regular file
Device: 252,1   Inode: 1310731     Links: 2
Access: (0644/-rw-r--r--)  Uid: ( 1000/  ubuntu)   Gid: ( 1000/  ubuntu)

Two fields on the third line matter. Inode is the number of the record that owns the data. Links is how many directory entries point at that record. Every name that shares the inode number is the same file, and the link count is the number of such names, so read Links as "how many places this file appears". A file that exists only in the library, with no seeding copy, shows one. A file that the torrent client still seeds and that Sonarr imported by hardlink shows one more than that, because two names now point at one inode.

Confirm it from the other side, by printing the inode and the link count for both names:

stat -c '%i %h %n' \
  "/srv/data/media/tv/Example Show/Season 01/Example Show - S01E01 - Pilot.mkv" \
  /srv/data/torrents/tv/Example.Show.S01E01.1080p.WEB.H264-GROUP/*.mkv
1310731 2 /srv/data/media/tv/Example Show/Season 01/Example Show - S01E01 - Pilot.mkv
1310731 2 /srv/data/torrents/tv/Example.Show.S01E01.1080p.WEB.H264-GROUP/Example.Show.S01E01.1080p.WEB.H264-GROUP.mkv

The first column is the inode, the second is the link count. The same inode on both lines means one file with two names. If you do not know where the torrent copy is, let the filesystem find every name for you:

find /srv/data -samefile "/srv/data/media/tv/Example Show/Season 01/Example Show - S01E01 - Pilot.mkv"

It prints every path that shares that inode. ls -li shows the same two numbers, the inode first and the link count third, if you prefer a directory listing.

A copy looks different in every one of these checks. The two paths print two different inode numbers, each carries a link count of one, find -samefile prints only the path you gave it, and df -h /srv/data shows that Used grew by the size of the file. Run df -h /srv/data before and after an import and the difference alone tells you which happened.

The remote path mapping trap

Sonarr does not watch the download folder for new files. It asks qBittorrent where a finished torrent is, then opens the path qBittorrent reports on its own filesystem. When those two views disagree, Sonarr raises this health check error:

You are using docker; download client qBittorrent places downloads in /downloads/tv but this directory does not appear to exist inside the container. Review your remote path mappings and container volume settings.

The message names the fix, in the order to try it. The layout above gives qBittorrent /data/torrents and Sonarr /data, so /data/torrents/tv/... is valid in both, and the message cannot appear. It appears when one container still has the old /downloads mount, and there are two ways out. The trap is the quick one: add a second volume line to Sonarr, /srv/data/torrents:/downloads, so the path exists. The health check clears and imports succeed. Every one of them is a copy, because Sonarr now links from the /downloads mount into the /data mount and gets EXDEV. The correct fix is to change the qBittorrent volume to /srv/data/torrents:/data/torrents and its save path to /data/torrents, so that both containers use one string for one folder.

Remote path mappings, under Settings, Download Clients, exist for a download client that runs on another machine, such as a seedbox or a NAS (network-attached storage). Sonarr reaches those files through a network share mounted into its container, and the mapping rewrites the prefix the remote client reports, say /home/seed/downloads/, to the path where the share appears inside Sonarr, say /mnt/seedbox/downloads/. Two things follow. A mapping is a plain string replacement, so the remote path must be entered exactly as the client reports it, trailing slash included. And a hardlink cannot join two hosts, so imports from a remote client are copies over the network, which is correct for that layout and not a fault. On a single VPS, needing a mapping at all means the volumes are inconsistent, and the volumes are what to fix.

Permissions: one inode, one owner

A hardlink shares the inode, and the inode holds the owner and the mode. There is no way to give the library name different permissions from the seeding name, because there is only one file. This is why the whole stack runs as one user id. The file is written by qBittorrent and linked by Sonarr, and Jellyfin has to read the result, so every container must agree on who owns it. Set the same PUID and PGID in every service, and chown -R the /srv/data tree to that id once, as above. What those two variables do inside a LinuxServer.io image, and why a mismatch shows up in the Sonarr log as Access to the path '...' is denied, is covered in the PUID and PGID explainer.

The mode of an imported file is decided by qBittorrent, since it wrote the file. The LinuxServer.io images take a UMASK environment variable, with a default of 022, which produces 0644 files that any user in the group can read. Sonarr's "Set Permissions" option under Media Management can chmod the file after import, and with a hardlink that change is visible from the torrent side too, which is harmless as long as the client runs as the same user. One kernel setting also matters: Ubuntu ships with fs.protected_hardlinks = 1, which lets a user hardlink only files they own or can both read and write. With a single PUID across the stack, that condition is always met.

FAQ

Do I need a remote path mapping when Sonarr and qBittorrent run on the same VPS?

No. On one host, both containers should mount the same host folder at the same container path: /srv/data/torrents as /data/torrents in qBittorrent, and /srv/data as /data in Sonarr, so the path qBittorrent reports is already valid inside Sonarr. A remote path mapping is a string replacement for a download client on another machine. Needing one on a single VPS means the volumes are inconsistent, and adding an extra /downloads mount to Sonarr to satisfy the health check turns every import into a copy.

Why does my disk fill up twice as fast with the arr stack?

Because each import is a copy rather than a hardlink, so every seeding torrent exists twice: once in the download folder and once in the library. Check one imported episode with stat and compare its Inode with the file in the torrent folder. Different inode numbers mean a copy. The usual cause is two separate bind mounts, such as /downloads and /media, in the Sonarr service. Replace them with one /srv/data:/data mount, and hardlinked imports take no extra space.

No. A hardlink is a second name for an inode, and an inode belongs to one filesystem. Linux refuses link() across filesystems, and across separate mounts of the same filesystem, with EXDEV, and Sonarr then copies. If your VPS has a large separate volume, put the entire /srv/data tree on it, including torrents, so both ends of the link are on that one filesystem. For a client on another host, copying over the network is the only option.

No. A usenet download is not seeded, so Sonarr moves it instead of copying it. Within one mount a move is a rename(), which is instant and needs no free space. Keep /data/usenet/incomplete and /data/usenet/complete under the same /srv/data tree as media, and mount that tree into Sonarr as one /data volume, so the move never has to cross a mount boundary and turn into a copy plus a delete.

Does Jellyfin need to see /data/torrents?

No. Jellyfin only reads the library, so mount /srv/data/media as /data/media and add /data/media/tv and /data/media/movies as its library folders. A hardlinked file is a normal file from Jellyfin's point of view. Adding the torrent folder as a library would scan half-written downloads and release-group folder names the scanner cannot parse.