homelab-blueprint/docs/DEPLOYMENT.md
2026-06-13 17:09:02 +02:00

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.

  1. Open the UniFi Drive web interface for the UNAS.
  2. Create or select the shared drive that will contain the arr data, for example Media.
  3. Enable NFS for that shared drive.
  4. 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.
  5. Grant that client read/write access.
  6. 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.

  • noatime avoids unnecessary access-time writes.
  • nofail allows Proxmox to boot if the UNAS is temporarily unavailable.
  • _netdev marks the mount as network-dependent.
  • x-systemd.automount makes access trigger the mount and reduces boot-order races.
  • x-systemd.mount-timeout=10s avoids 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
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:

  • 120 is the LXC VMID.
  • mp0 is 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 101000 cannot, ownership/mode or the LXC ID mapping is wrong.
  • If neither host root nor UID 101000 can write, check the UNAS export's read/write setting, client allowlist, ACLs, and root squash behavior.
  • If chown itself returns Operation 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 /movies or /downloads roots.

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 /tv or /downloads roots.

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

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