19 KiB
Proxmox LXC arr-stack Deployment
This walkthrough deploys the arr stack inside an unprivileged Docker LXC while keeping:
- app configs and SQLite databases on local LXC disk at
/opt/arr-stack/appdata; - downloads and media on a UniFi UNAS Pro NFS share;
- NFS mounted only on the Proxmox host;
- the NFS-backed directory bind-mounted into the LXC at
/mnt/unas/arr-data; - the same shared root visible inside relevant Docker containers at
/data.
The storage path is:
UNAS NFS export
-> Proxmox host: /mnt/unas/arr-data
-> unprivileged LXC: /mnt/unas/arr-data
-> Docker containers: /data
Do not start the Compose stack until the storage verification steps pass.
Values Used in This Guide
Replace these examples with your real values:
| Name | Example | Meaning |
|---|---|---|
UNAS_IP |
10.0.10.20 |
UNAS Pro IP address |
PVE_IP |
10.0.10.10 |
Proxmox host IP allowed by the UNAS |
VMID |
120 |
Docker LXC container ID |
NFS_EXPORT |
/volume1/.srv/.unifi-drive/Media/.data |
Real export from showmount |
HOST_DATA |
/mnt/unas/arr-data |
NFS mountpoint on Proxmox |
LXC_DATA |
/mnt/unas/arr-data |
Bind-mount destination inside the LXC |
CONFIG_ROOT |
/opt/arr-stack/appdata |
Local LXC config path |
PUID:PGID |
1000:1000 |
Identity used by the containers |
IDMAP_BASE |
100000 |
Default unprivileged LXC ID-map base |
Run Proxmox commands from the Proxmox shell as root. Run LXC commands after
entering the LXC with pct enter 120.
1. Enable NFS on the UNAS
Why: the Proxmox host needs permission to mount the shared drive. The LXC must not mount NFS itself.
- Open the UniFi Drive web interface for the UNAS.
- Create or select the shared drive that will contain the arr data, for example
Media. - Enable NFS for that shared drive.
- Add the Proxmox host IP, for example
10.0.10.10, to the NFS allowed clients/hosts list. Do not add only the LXC IP because Proxmox is the NFS client. - Grant that client read/write access.
- Save/apply the NFS settings.
UniFi Drive interface labels can change between releases. Look for the shared
drive's sharing/protocol settings, then NFS and allowed clients. The critical
result is that PVE_IP has read/write NFS access.
2. Discover the Real NFS Export Path
Why: the friendly path displayed in the UniFi Drive dashboard is not necessarily
the NFS export path. UNAS exports commonly use a path similar to
/volume1/.srv/.unifi-drive/<Share>/.data. Always use the path reported by
showmount.
On the Proxmox host, install the NFS client tools:
apt update
apt install -y nfs-common
Expected result ends with:
Setting up nfs-common ...
Set the UNAS address and list its exports:
UNAS_IP=10.0.10.20
showmount -e "$UNAS_IP"
Expected output resembles:
Export list for 10.0.10.20:
/volume1/.srv/.unifi-drive/Media/.data 10.0.10.10
Copy the exact export path from this output:
NFS_EXPORT=/volume1/.srv/.unifi-drive/Media/.data
printf '%s\n' "$NFS_EXPORT"
Expected output:
/volume1/.srv/.unifi-drive/Media/.data
If showmount reports RPC: Program not registered, NFSv4 may still work even
though the server does not expose the older mount-discovery RPC service. Recheck
that NFS is enabled, then obtain the exact export from the UNAS NFS settings. If
it reports access denied, fix the allowed client entry so it contains the
Proxmox host IP.
3. Mount NFS on the Proxmox Host
Why: Proxmox must own the NFS connection so it can pass the mounted storage into the unprivileged LXC.
Set the values for this shell session:
UNAS_IP=10.0.10.20
NFS_EXPORT=/volume1/.srv/.unifi-drive/Media/.data
HOST_DATA=/mnt/unas/arr-data
Create the empty host mountpoint:
mkdir -p "$HOST_DATA"
Expected output: none.
Test NFS 4.1 by hand before editing /etc/fstab:
mount -t nfs -o vers=4.1,noatime "$UNAS_IP:$NFS_EXPORT" "$HOST_DATA"
Expected output: none. Verify it:
findmnt "$HOST_DATA"
nfsstat -m
Expected findmnt output resembles:
TARGET SOURCE FSTYPE OPTIONS
/mnt/unas/arr-data 10.0.10.20:/volume1/.srv/.unifi-drive/Media/.data nfs4 rw,...
Expected nfsstat -m output includes vers=4.1.
If the 4.1 mount fails with a protocol/version error, retry NFSv3:
umount "$HOST_DATA" 2>/dev/null || true
mount -t nfs -o vers=3,noatime "$UNAS_IP:$NFS_EXPORT" "$HOST_DATA"
findmnt "$HOST_DATA"
Use the version that succeeds in the permanent entry below.
Test basic NFS write access:
touch "$HOST_DATA/.pve-nfs-test"
ls -ln "$HOST_DATA/.pve-nfs-test"
rm "$HOST_DATA/.pve-nfs-test"
Expected output resembles the following, although a squashed NFS root identity may show a different numeric owner:
-rw-r--r-- 1 0 0 0 Jun 11 12:00 /mnt/unas/arr-data/.pve-nfs-test
If touch returns Permission denied, stop here and fix UNAS export permissions.
Back up and edit /etc/fstab:
cp /etc/fstab /etc/fstab.backup-before-unas
nano /etc/fstab
Add one line, replacing the IP and export path:
10.0.10.20:/volume1/.srv/.unifi-drive/Media/.data /mnt/unas/arr-data nfs vers=4.1,noatime,nofail,_netdev,x-systemd.automount,x-systemd.mount-timeout=10s 0 0
Use vers=3 instead if the earlier NFSv3 test was required.
noatimeavoids unnecessary access-time writes.nofailallows Proxmox to boot if the UNAS is temporarily unavailable._netdevmarks the mount as network-dependent.x-systemd.automountmakes access trigger the mount and reduces boot-order races.x-systemd.mount-timeout=10savoids a long mount stall when storage is down.
Apply and verify the permanent entry:
umount "$HOST_DATA"
systemctl daemon-reload
mount -a
ls "$HOST_DATA" >/dev/null
findmnt "$HOST_DATA"
nfsstat -m
Expected result: findmnt again shows the UNAS source, and nfsstat -m shows the
selected NFS version.
4. Bind Mount the Host Path into the LXC
Why: the LXC cannot mount NFS directly. A Proxmox mountpoint passes the already mounted host directory into the container.
Confirm the LXC is unprivileged:
VMID=120
pct config "$VMID" | grep '^unprivileged:'
Expected output:
unprivileged: 1
Stop the LXC before changing mountpoints. pct set edits the configuration, but
a stop/start is required for the new mount to appear reliably:
pct stop "$VMID"
Expected output: none. Confirm it is stopped:
pct status "$VMID"
Expected output:
status: stopped
Method A: pct set (recommended)
pct set "$VMID" -mp0 /mnt/unas/arr-data,mp=/mnt/unas/arr-data
Expected output resembles:
update CT 120: -mp0 /mnt/unas/arr-data,mp=/mnt/unas/arr-data
Syntax:
120is the LXC VMID.mp0is the first Proxmox mountpoint slot.- the first path is the Proxmox host path.
mp=is the destination path inside the LXC.
Method B: edit the LXC config directly
Use this only instead of Method A. Open:
nano "/etc/pve/lxc/$VMID.conf"
Add:
mp0: /mnt/unas/arr-data,mp=/mnt/unas/arr-data
Both methods produce the same configuration. Confirm it:
pct config "$VMID" | grep '^mp0:'
Expected output:
mp0: /mnt/unas/arr-data,mp=/mnt/unas/arr-data
Start and enter the LXC:
pct start "$VMID"
pct enter "$VMID"
Inside the LXC, verify the path:
ls -la /mnt/unas/arr-data
findmnt -T /mnt/unas/arr-data
Expected result: ls shows the UNAS contents and findmnt identifies
/mnt/unas/arr-data as a mounted path. If it shows only the LXC root filesystem,
exit the LXC and recheck the Proxmox NFS mount and mp0 configuration.
5. Understand and Fix Unprivileged LXC UID/GID Mapping
Why: an unprivileged LXC shifts IDs to protect the Proxmox host. By default:
LXC uid 0 -> Proxmox host uid 100000
LXC uid 1000 -> Proxmox host uid 101000
The formula is:
host ID = IDMAP_BASE + LXC ID
This guide uses container/LXC user 1000:1000, so the NFS files must be
writable as host-side numeric ID 101000:101000.
Inside the LXC, create a named verification user if UID 1000 does not already exist:
getent group 1000 || groupadd --gid 1000 arrstack
getent passwd 1000 || useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash arrstack
getent passwd 1000
ARR_USER="$(getent passwd 1000 | cut -d: -f1)"
printf 'Using LXC verification user: %s\n' "$ARR_USER"
Expected final output resembles:
arrstack:x:1000:1000::/home/arrstack:/bin/bash
Using LXC verification user: arrstack
Inside the LXC, inspect numeric ownership:
ls -ldn /mnt/unas/arr-data
Exit to the Proxmox host and inspect the same path:
exit
ls -ldn /mnt/unas/arr-data
The numeric owners can differ because the LXC view is shifted. Set configurable values and calculate the host-side IDs:
PUID=1000
PGID=1000
IDMAP_BASE=100000
HOST_UID=$((IDMAP_BASE + PUID))
HOST_GID=$((IDMAP_BASE + PGID))
printf 'LXC %s:%s -> Proxmox %s:%s\n' "$PUID" "$PGID" "$HOST_UID" "$HOST_GID"
Expected output:
LXC 1000:1000 -> Proxmox 101000:101000
Set ownership on the NFS-backed arr data. This can take time for existing data:
chown -R "$HOST_UID:$HOST_GID" /mnt/unas/arr-data
ls -ldn /mnt/unas/arr-data
Expected owner:
drwxr-xr-x ... 101000 101000 ... /mnt/unas/arr-data
If chown returns Operation not permitted, the UNAS is rejecting root's
ownership change, commonly because of root squash or an export ACL. Fix the NFS
permissions on the UNAS or deliberately assign matching numeric ownership from
an identity the UNAS permits. Do not weaken the LXC to privileged mode.
Test the mapped identity from the Proxmox host:
setpriv --reuid="$HOST_UID" --regid="$HOST_GID" --clear-groups \
touch /mnt/unas/arr-data/.mapped-host-write-test
ls -ln /mnt/unas/arr-data/.mapped-host-write-test
rm /mnt/unas/arr-data/.mapped-host-write-test
Expected owner is 101000 101000.
Enter the LXC and prove that UID 1000 can read and write:
pct enter "$VMID"
ARR_USER="$(getent passwd 1000 | cut -d: -f1)"
su -s /bin/sh -c 'touch /mnt/unas/arr-data/.lxc-write-test && cat /mnt/unas/arr-data/.lxc-write-test >/dev/null && rm /mnt/unas/arr-data/.lxc-write-test' "$ARR_USER"
echo $?
Expected output:
0
Create the marker required by the Compose storage guard:
su -s /bin/sh -c 'touch /mnt/unas/arr-data/.arr-stack-storage' "$ARR_USER"
ls -ln /mnt/unas/arr-data/.arr-stack-storage
Expected owner inside the LXC is 1000 1000.
6. Common Storage Failures
Permission denied on write
Check each boundary:
# Proxmox host
findmnt /mnt/unas/arr-data
ls -ldn /mnt/unas/arr-data
setpriv --reuid=101000 --regid=101000 --clear-groups \
touch /mnt/unas/arr-data/.permission-test
- If host root can write but numeric UID
101000cannot, ownership/mode or the LXC ID mapping is wrong. - If neither host root nor UID
101000can write, check the UNAS export's read/write setting, client allowlist, ACLs, and root squash behavior. - If
chownitself returnsOperation not permitted, the NFS server is rejecting ownership changes; this is not caused by Docker.
Remove the test if it succeeded:
rm -f /mnt/unas/arr-data/.permission-test
Mount missing after Proxmox reboot
systemctl status mnt-unas-arr\\x2ddata.automount --no-pager
systemctl status mnt-unas-arr\\x2ddata.mount --no-pager
grep '/mnt/unas/arr-data' /etc/fstab
ls /mnt/unas/arr-data >/dev/null
findmnt /mnt/unas/arr-data
Expected result: the automount is active and accessing the directory causes the
NFS mount to appear. Check typos in the export path and /etc/fstab.
Stale mount after a UNAS reboot
Symptoms include Stale file handle, commands hanging, or I/O errors:
findmnt /mnt/unas/arr-data
nfsstat -m
umount -l /mnt/unas/arr-data
systemctl restart mnt-unas-arr\\x2ddata.automount
ls /mnt/unas/arr-data >/dev/null
findmnt /mnt/unas/arr-data
Stop the arr stack before forcing recovery if containers are actively using the mount.
LXC starts before the host NFS mount is ready
nofail,_netdev,x-systemd.automount lets Proxmox boot and makes first access
trigger the NFS mount. The Compose storage-guard adds a second safety layer:
data-consuming services start only when the marker exists and UID 1000 can
write. The long bind syntax also sets create_host_path: false.
Check the guard:
docker compose -f docker-compose.arr-stack.yml ps -a storage-guard
docker compose -f docker-compose.arr-stack.yml logs storage-guard
Success output:
Shared data mount marker and write access verified.
Missing or unwritable storage produces an error and blocks dependent services.
7. Prepare Local Config and Shared Data
Only continue after all previous storage tests pass.
Inside the LXC, install Docker Engine and the Compose plugin using your normal Debian/Ubuntu Docker installation process, then verify:
docker version
docker compose version
Expected output includes both a Docker Engine version and a Docker Compose version.
Create local config directories on the LXC root disk:
CONFIG_ROOT=/opt/arr-stack/appdata
mkdir -p "$CONFIG_ROOT"/{gluetun,qbittorrent,sabnzbd,prowlarr,radarr,sonarr,bazarr,emby,seerr,recyclarr,unpackerr}
chown -R 1000:1000 /opt/arr-stack
findmnt -T "$CONFIG_ROOT"
Expected result: findmnt shows the LXC's local root filesystem, not the
UNAS NFS source.
Create the TRaSH-style shared directory tree:
ARR_USER="$(getent passwd 1000 | cut -d: -f1)"
su -s /bin/bash -c 'mkdir -p /mnt/unas/arr-data/torrents/{movies,tv} /mnt/unas/arr-data/usenet/{incomplete,complete/{movies,tv}} /mnt/unas/arr-data/media/{movies,tv}' "$ARR_USER"
find /mnt/unas/arr-data -maxdepth 3 -type d | sort
Expected output includes:
/mnt/unas/arr-data/media/movies
/mnt/unas/arr-data/media/tv
/mnt/unas/arr-data/torrents/movies
/mnt/unas/arr-data/torrents/tv
/mnt/unas/arr-data/usenet/complete/movies
/mnt/unas/arr-data/usenet/complete/tv
/mnt/unas/arr-data/usenet/incomplete
Clone or copy this repository into the LXC, then from its root:
cp .env.sample .env
nano .env
Confirm these values:
DOCKERCONFDIR=/opt/arr-stack/appdata
DOCKERSTORAGEDIR=/mnt/unas/arr-data
ARR_DATA_MARKER=.arr-stack-storage
PUID=1000
PGID=1000
LXC_IDMAP_BASE=100000
Fill in the remaining VPN/API values, then render and validate:
./substitute_env.sh docker-compose-files/arr-stack/template.yaml docker-compose.arr-stack.yml .env
docker compose -f docker-compose.arr-stack.yml config
Expected result: substitution reports success and Compose prints the normalized configuration without errors.
If the LXC has no /dev/dri GPU passthrough, remove or comment the Emby
devices and group_add entries in the generated Compose file before starting
the stack. Otherwise Docker will reject the missing device.
Start the stack:
docker compose -f docker-compose.arr-stack.yml up -d
docker compose -f docker-compose.arr-stack.yml ps -a storage-guard
docker compose -f docker-compose.arr-stack.yml logs storage-guard
Expected guard log:
Shared data mount marker and write access verified.
8. Configure Paths Inside the Applications
Why: hardlinks require download clients and arr applications to use paths under
the same /data prefix. Do not configure /downloads, /tv, or /movies.
qBittorrent
Set category save paths:
| Category | Save path |
|---|---|
movies |
/data/torrents/movies |
tv |
/data/torrents/tv |
In Radarr and Sonarr, configure qBittorrent using host gluetun, port 8080,
and the matching category. No remote path mapping is needed.
SABnzbd
Set:
| Setting | Path |
|---|---|
| Temporary Download Folder | /data/usenet/incomplete |
| Completed Download Folder | /data/usenet/complete |
movies category folder |
movies |
tv category folder |
tv |
In Radarr and Sonarr, configure SABnzbd using host sabnzbd, port 8282, and
the matching category. No remote path mapping is needed.
Radarr
- Root folder:
/data/media/movies - Enable completed download handling.
- Enable hardlinks instead of copy where the setting is available.
- Remove obsolete remote path mappings and old
/moviesor/downloadsroots.
Sonarr
- Root folder:
/data/media/tv - Enable completed download handling.
- Enable hardlinks instead of copy where the setting is available.
- Remove obsolete remote path mappings and old
/tvor/downloadsroots.
Bazarr and Emby
- Bazarr movie path:
/data/media/movies - Bazarr series path:
/data/media/tv - Emby movie library:
/data/media/movies - Emby TV library:
/data/media/tv
9. Verify Hardlinks
First prove the filesystem supports a hardlink across the intended download and media directories:
ARR_USER="$(getent passwd 1000 | cut -d: -f1)"
su -s /bin/sh -c 'printf test > /mnt/unas/arr-data/torrents/movies/hardlink-source.test' "$ARR_USER"
su -s /bin/sh -c 'ln /mnt/unas/arr-data/torrents/movies/hardlink-source.test /mnt/unas/arr-data/media/movies/hardlink-import.test' "$ARR_USER"
stat -c '%h %n' /mnt/unas/arr-data/torrents/movies/hardlink-source.test /mnt/unas/arr-data/media/movies/hardlink-import.test
Expected link count is 2 for both names:
2 /mnt/unas/arr-data/torrents/movies/hardlink-source.test
2 /mnt/unas/arr-data/media/movies/hardlink-import.test
Clean up:
rm /mnt/unas/arr-data/torrents/movies/hardlink-source.test
rm /mnt/unas/arr-data/media/movies/hardlink-import.test
Finally, import a real torrent through Radarr or Sonarr. A correct hardlink import is nearly instant, does not temporarily consume a second file's worth of space, and leaves the seeding download in place. Check it:
stat -c '%h %n' /mnt/unas/arr-data/torrents/movies/<downloaded-file> /mnt/unas/arr-data/media/movies/<imported-file>
A link count greater than 1 proves both names reference the same file data.
Volume Mapping Changes
All config mappings remain under DOCKERCONFDIR and must resolve to local LXC
disk. Shared-data mappings use one long-syntax bind with
create_host_path: false.
| Service | Before | After | Rationale |
|---|---|---|---|
| storage-guard | none | DOCKERSTORAGEDIR -> /data |
Blocks startup without marker/write access |
| qBittorrent | DOCKERSTORAGEDIR/torrents -> /data/torrents |
DOCKERSTORAGEDIR -> /data |
One shared mount and consistent paths |
| SABnzbd | DOCKERSTORAGEDIR/usenet -> /data/usenet |
DOCKERSTORAGEDIR -> /data |
One shared mount and consistent paths |
| Radarr | DOCKERSTORAGEDIR -> /data short syntax |
same path using guarded long syntax | Prevent fallback directory creation |
| Sonarr | DOCKERSTORAGEDIR -> /data short syntax |
same path using guarded long syntax | Prevent fallback directory creation |
| Bazarr | DOCKERSTORAGEDIR/media -> /data/media |
DOCKERSTORAGEDIR -> /data |
Consistent shared root |
| Emby | DOCKERSTORAGEDIR/media -> /data/media |
DOCKERSTORAGEDIR -> /data |
Consistent shared root |
| Seerr | DOCKERSTORAGEDIR/media -> /data/media |
removed | Seerr does not need filesystem access |
| Unpackerr | DOCKERSTORAGEDIR -> /data short syntax |
same path using guarded long syntax | Prevent fallback directory creation |
| Decluttarr | DOCKERSTORAGEDIR -> /data short syntax |
same path using guarded long syntax | Prevent fallback directory creation |