- Add compose stacks for apps already proxied by Caddy: bento-pdf,
it-tools, paperless-ngx, and beszel-hub (with Makefile targets)
- Remove the unused Vault stack and the drawio Caddyfile block
- Fix Caddyfile gramps upstream to port 80 to match the compose file
- Bring immich and gramps-web up to the shared template pattern
(no-new-privileges, log rotation, memory limits, healthchecks)
- Single-quote vaultwarden SSO_AUDIENCE_TRUSTED so regex values like
^\d{18}$ render as valid YAML
- Move root docs into kebab-case topic folders, rebuild the docs index
to cover every doc, and fix all broken links
17 KiB
Arr Stack
Docker media stack with Gluetun, download clients, media managers, Emby, Recyclarr, Unpackerr, and Decluttarr. Run repository commands from the project root.
Appdata Location
This stack is the exception to project-root appdata/:
DOCKERCONFDIRstores application configs on local disk.DOCKERSTORAGEDIRstores shared downloads and media.
The default paths are /opt/arr-stack/appdata and /mnt/unas/arr_data. Complete
the storage deployment guide before starting.
Recommended LXC Mode
- Privileged LXC: Required for NVIDIA GPU passthrough (Emby) and easier NFS mapping.
- Unprivileged LXC: Supported for CPU-only transcoding, but requires manual UID/GID mapping for NFS write access.
UNAS Pro Shared-Drive Structure
Create one UNAS Pro shared drive for the entire Arr data root, for example
arr_data, and enable NFS for that shared drive. Mount its NFS export on the
Proxmox host at /mnt/unas/arr_data, then bind-mount that same path into the
LXC.
The root of the UNAS shared drive must contain the marker and the three TRaSH-style directories:
arr_data/ # UNAS shared-drive root
|-- .arr-stack-storage
|-- torrents/
| |-- incomplete/
| |-- movies/
| `-- tv/
|-- usenet/
| |-- incomplete/
| `-- complete/
| |-- movies/
| `-- tv/
`-- media/
|-- movies/
`-- tv/
When that shared-drive root is mounted at /mnt/unas/arr_data, the LXC must
see:
/mnt/unas/arr_data/.arr-stack-storage
/mnt/unas/arr_data/torrents/incomplete
/mnt/unas/arr_data/torrents/movies
/mnt/unas/arr_data/torrents/tv
/mnt/unas/arr_data/usenet/incomplete
/mnt/unas/arr_data/usenet/complete/movies
/mnt/unas/arr_data/usenet/complete/tv
/mnt/unas/arr_data/media/movies
/mnt/unas/arr_data/media/tv
Do not create another nested /mnt/unas/arr_data/arr_data directory. All
download and library directories must remain on this one NFS export and
filesystem so hardlinks and atomic moves can work.
Create the tree from inside the LXC as the configured PUID:
ARR_USER="$(getent passwd 1000 | cut -d: -f1)"
su -s /bin/bash -c '
mkdir -p \
/mnt/unas/arr_data/torrents/{incomplete,movies,tv} \
/mnt/unas/arr_data/usenet/{incomplete,complete/{movies,tv}} \
/mnt/unas/arr_data/media/{movies,tv}
touch /mnt/unas/arr_data/.arr-stack-storage
' "$ARR_USER"
Use one consistent mapped owner/group and the TRaSH-recommended 775
directory / 664 file permissions:
find /mnt/unas/arr_data -type d -exec chmod 775 {} +
find /mnt/unas/arr_data -type f -exec chmod 664 {} +
This guide assumes a Privileged LXC where UID 1000 inside the container
maps 1:1 to UID 1000 on the Proxmox host. Set ownership from the Proxmox
host (not from inside the LXC):
chown -R 1000:1000 /mnt/unas/arr_data
This requires the arr_data share to have No Root Squash (Isolated Mode)
set in UniFi Drive. Without it, the Proxmox host root is squashed to an
anonymous UID (977:988) that does not own the export and cannot change ownership.
See docs/unas/nfs-mounts.md step 1 for how to set this.
TRaSH-Aligned Container Mounts
The Compose template uses YAML anchors to keep long bind-mount definitions consistent. After setting:
DOCKERSTORAGEDIR=/mnt/unas/arr_data
*arr_data-mount expands to:
type: bind
source: /mnt/unas/arr_data
target: /data
bind:
create_host_path: false
This full-root mount is used by the storage guard, Radarr, and Sonarr. These
Starr apps must see downloads and media through the same /data mount for
hardlinks and atomic moves.
The restricted anchors expand as follows:
# *torrents-data-mount
source: /mnt/unas/arr_data/torrents
target: /data/torrents
# *usenet-data-mount
source: /mnt/unas/arr_data/usenet
target: /data/usenet
# *media-data-mount
source: /mnt/unas/arr_data/media
target: /data/media
| Service | Shared-data mount inside container |
|---|---|
| Storage guard | /data |
| Radarr, Sonarr | /data |
| qBittorrent | /data/torrents |
| SABnzbd | /data/usenet |
| Bazarr, Emby, Decluttarr | /data/media |
| Unpackerr | /data/torrents and /data/usenet |
| Prowlarr, FlareSolverr, Seerr, Recyclarr | None |
Do not add separate /movies, /tv, or /downloads mounts. Inside the apps,
use /data/torrents/..., /data/usenet/..., and /data/media/....
This layout follows the TRaSH Guides Docker folder structure and keeps download and library paths on one filesystem for hardlinks and atomic moves.
Recommended LXC Allocation
The current Compose template reserves approximately 4.1 GB of memory across
the stack and permits approximately 15.3 GB at the combined container
limits. Emby, SABnzbd unpacking, qBittorrent, and library scans create the
largest resource spikes.
| Resource | Recommended starting point | Minimum |
|---|---|---|
| CPU | 4 cores | 2 cores |
| RAM | 8 GB | 6 GB |
| Swap | 2 GB | 1 GB |
| LXC root disk | 64 GB | 32 GB |
| GPU | NVIDIA RTX A400 passthrough for Emby | Optional |
Container memory limits (sized for 25 GB average downloads):
| Service | Limit | Reservation | Notes |
|---|---|---|---|
| gluetun | 512M | 128M | WireGuard userspace daemon; stable |
| qbittorrent | 3G | 256M | Piece-verification buffers at high progress for 25 GB+ torrents require >1G |
| flaresolverr | 1G | 256M | Headless Chromium; short-lived per-request spikes |
| sabnzbd | 2G | 512M | par2 repair of 25 GB NZBs can spike 1.2–1.8 GiB |
| prowlarr | 1G | 256M | RSS sync across many indexers spikes the 198 MiB idle baseline |
| radarr | 1G | 256M | Library scans are I/O-bound; 1G comfortable |
| sonarr | 1G | 256M | Same as Radarr |
| bazarr | 1G | 256M | 345 MiB at idle (67% of old 512M); subtitle batch on new 4K imports adds 200–300 MiB |
| emby | 3G | 1G | NVIDIA HW transcode keeps RAM library-metadata-bound; 3G comfortable |
| seerr | 1G | 256M | 216 MiB at idle; Emby library sync on startup spikes to ~450 MiB |
| recyclarr | 256M | 64M | Scheduled sync tool; peaks <80 MiB |
| unpackerr | 512M | 128M | Shells out to system unrar/7z; own RSS stays <50 MiB |
| decluttarr | 512M | 128M | Polling daemon; no large data structures |
qBittorrent memory note: piece-verification buffers for large torrents (25 GB+) can push qBittorrent well past 1 GiB during active download. The template sets a 3 GiB limit. Do not lower it below 2 GiB or the cgroup OOM killer will terminate the process mid-download, causing the torrent to appear to restart from a lower percentage. Use
docker update --memory 3g --memory-swap 6g qbittorrentto apply the limit live without interrupting an in-progress download.
Use 12 GB RAM and 6 CPU cores for multiple simultaneous Emby streams, large
libraries, heavy SABnzbd unpacking, or hundreds of active torrents. The emby
service is configured to use the nvidia runtime; ensure the Proxmox host
drivers and the NVIDIA Container Toolkit are installed and configured according
to the NVIDIA GPU Passthrough guide.
Without GPU passthrough, software transcoding can consume all available CPU;
avoid transcoding or allocate substantially more CPU.
Downloads and media live on DOCKERSTORAGEDIR, so the LXC root disk primarily
stores Docker images, logs, application configs, SQLite databases, and Emby
metadata. Allocate 100 GB instead of 64 GB if Emby generates extensive
thumbnails or chapter previews.
After several days of representative usage, check actual peaks:
docker stats --no-stream
free -h
df -h /
Increase LXC RAM if usage regularly approaches the allocation or swap usage continues growing.
Installation
-
Create
.envif it does not exist:cp -n .env.sample .env -
Set the Arr Stack values in
.env:# Local LXC config storage and NFS-backed shared media/download storage DOCKERCONFDIR=/opt/arr-stack/appdata DOCKERSTORAGEDIR=/mnt/unas/arr_data ARR_DATA_MARKER=.arr-stack-storage # Container identity; must match storage ownership PUID=1000 PGID=1000 TZ=Africa/Johannesburg # Docker logs DOCKERLOGGING_MAXFILE=3 DOCKERLOGGING_MAXSIZE=10m # Gluetun / AirVPN WireGuard VPN_SERVICE_PROVIDER=airvpn WIREGUARD_PRIVATE_KEY=<airvpn-wireguard-private-key> WIREGUARD_PRESHARED_KEY=<airvpn-wireguard-preshared-key> WIREGUARD_ADDRESSES=<airvpn-wireguard-address> FIREWALL_VPN_INPUT_PORTS=<forwarded-port-or-empty> SERVER_COUNTRIES=<preferred-country-or-empty> # Required after initial Sonarr/Radarr setup SONARR_API_KEY= RADARR_API_KEY= # Optional integrations SABNZBD_API_KEY= QBITTORRENT_BASE_URL=http://gluetun:8080 QBITTORRENT_USERNAME= QBITTORRENT_PASSWORD= # Decluttarr queue cleanup DECLUTTARR_TEST_RUN=True DECLUTTARR_TIMER=10 DECLUTTARR_MAX_STRIKES=3 DECLUTTARR_LOG_LEVEL=INFO DECLUTTARR_DETECT_DELETIONS=TrueDOCKERCONFDIRmust be local LXC storage.DOCKERSTORAGEDIRmust be the mounted shared-data path and must containARR_DATA_MARKER.Obtain the WireGuard values and forwarded port from AirVPN. Leave
FIREWALL_VPN_INPUT_PORTSempty if no inbound forwarded port is configured.SERVER_COUNTRIESmay also remain empty.AirVPN preshared key warning: AirVPN generates a unique preshared key for each device-server combination. The built-in
airvpnGluetun provider selects a server dynamically, which will not match the preshared key in your.env. This causes a silent WireGuard failure — the tunnel appears to connect but DNS lookups time out through it. If you see this symptom, useVPN_SERVICE_PROVIDER=customand pin a specific server instead:VPN_SERVICE_PROVIDER=custom WIREGUARD_PRIVATE_KEY=<from-airvpn-wireguard-config> WIREGUARD_PRESHARED_KEY=<from-airvpn-wireguard-config> WIREGUARD_ADDRESSES=<from-airvpn-wireguard-config> WIREGUARD_PUBLIC_KEY=<peer-PublicKey-from-airvpn-wireguard-config> WIREGUARD_ENDPOINT_IP=<resolved-ip-of-server-hostname> WIREGUARD_ENDPOINT_PORT=<port-from-airvpn-wireguard-config> FIREWALL_VPN_INPUT_PORTS=<forwarded-port-or-empty> # Do NOT set SERVER_COUNTRIES or SERVER_HOSTNAMES — the custom provider rejects themAll four WireGuard values (
PrivateKey,PresharedKey,Address,PublicKey, andEndpoint) come from the WireGuard config file downloaded from the AirVPN client area for a specific server. TheWIREGUARD_ENDPOINT_IPis the resolved IP address of the server hostname in theEndpointfield (usedig +short <hostname>to resolve it). LeaveSERVER_COUNTRIESempty or absent.Sonarr and Radarr API keys do not exist until those applications complete their first startup. Leave them empty during the bootstrap phase below. Keep
DECLUTTARR_TEST_RUN=Trueuntil its actions have been reviewed; change it toFalseonly when ready to enable queue cleanup. -
Create config directories, verify shared storage, and render the initial Compose file:
sudo mkdir -p /opt/arr-stack/appdata/{gluetun,qbittorrent,sabnzbd,prowlarr,radarr,sonarr,bazarr,emby,seerr,recyclarr,unpackerr} sudo chown -R 1000:1000 /opt/arr-stack sudo -u '#1000' touch /mnt/unas/arr_data/.arr-stack-storage ./substitute_env.sh docker-compose-files/arr-stack/template.yaml docker-compose.arr-stack.yml .envDo not render
recyclarr.ymlyet. Empty API keys produce an unusable Recyclarr configuration. -
Validate Compose and start only the bootstrap services:
docker compose -f docker-compose.arr-stack.yml config --quiet docker compose -f docker-compose.arr-stack.yml up -d \ storage-guard gluetun qbittorrent flaresolverr sabnzbd prowlarr \ radarr sonarr bazarr emby seerrThis intentionally leaves Recyclarr, Unpackerr, and Decluttarr stopped until their API credentials are available.
-
Verify the bootstrap services and VPN:
docker compose -f docker-compose.arr-stack.yml ps docker exec gluetun wget -qO- https://ipinfo.io/ip -
Complete initial Sonarr and Radarr setup:
- Open Sonarr at
http://<HOST_IP>:8989. - Open Radarr at
http://<HOST_IP>:7878. - Configure their root folders and download clients as described under Initial Setup below.
- Copy each API key from Settings > General > Security.
- If using Decluttarr with SABnzbd or authenticated qBittorrent, also collect those credentials.
Update
.env:SONARR_API_KEY=<sonarr-api-key> RADARR_API_KEY=<radarr-api-key> SABNZBD_API_KEY=<sabnzbd-api-key-or-empty> QBITTORRENT_USERNAME=<username-or-empty> QBITTORRENT_PASSWORD=<password-or-empty> - Open Sonarr at
-
Render the final Compose file and Recyclarr configuration:
./substitute_env.sh docker-compose-files/arr-stack/template.yaml docker-compose.arr-stack.yml .env ./substitute_env.sh \ docker-compose-files/arr-stack/config/recyclarr_template.yml \ /opt/arr-stack/appdata/recyclarr/recyclarr.yml \ .env sudo chown 1000:1000 /opt/arr-stack/appdata/recyclarr/recyclarr.yml sudo chmod 640 /opt/arr-stack/appdata/recyclarr/recyclarr.yml docker compose -f docker-compose.arr-stack.yml config --quietThe rendered Recyclarr configuration uses Docker service URLs
http://sonarr:8989andhttp://radarr:7878. Do not replace them withlocalhost; Recyclarr runs in its own container. -
Start Recyclarr and preview its TRaSH Guides changes:
docker compose -f docker-compose.arr-stack.yml up -d recyclarr docker compose -f docker-compose.arr-stack.yml exec recyclarr recyclarr config list docker compose -f docker-compose.arr-stack.yml exec recyclarr recyclarr sync --previewReview the preview before applying it. The repository template creates the documented Sonarr and Radarr quality profiles, quality definitions, custom formats, and scores.
-
Apply Recyclarr and start the remaining services:
docker compose -f docker-compose.arr-stack.yml exec recyclarr recyclarr sync docker compose -f docker-compose.arr-stack.yml up -d docker compose -f docker-compose.arr-stack.yml psSee Recyclarr Configuration Guide before changing the supplied quality profiles or custom-format scores.
Apply Configuration Changes
After changing .env or a template, render the affected file again and
recreate the stack:
./substitute_env.sh docker-compose-files/arr-stack/template.yaml docker-compose.arr-stack.yml .env
./substitute_env.sh docker-compose-files/arr-stack/config/recyclarr_template.yml /opt/arr-stack/appdata/recyclarr/recyclarr.yml .env
sudo chown 1000:1000 /opt/arr-stack/appdata/recyclarr/recyclarr.yml
sudo chmod 640 /opt/arr-stack/appdata/recyclarr/recyclarr.yml
docker compose -f docker-compose.arr-stack.yml config --quiet
docker compose -f docker-compose.arr-stack.yml exec recyclarr recyclarr sync --preview
docker compose -f docker-compose.arr-stack.yml up -d
Apply recyclarr sync only after reviewing the preview.
Initial Setup
After starting the stack, configure the applications using these internal
container paths. Do not add separate /movies, /tv, or /downloads mounts.
| Application | Path |
|---|---|
| qBittorrent default save path | /data/torrents |
| qBittorrent incomplete path | /data/torrents/incomplete |
| qBittorrent categories | movies → /data/torrents/movies; tv → /data/torrents/tv |
| SABnzbd | /data/usenet/incomplete, /data/usenet/complete |
| Radarr | /data/media/movies |
| Sonarr | /data/media/tv |
| Bazarr / Emby | /data/media/movies, /data/media/tv |
In Radarr and Sonarr, configure qBittorrent at gluetun:8080 and SABnzbd at
sabnzbd:8282. Set Radarr's category to movies and Sonarr's category to
tv. No remote path mapping is needed because all applications use the same
container paths. In Settings > Media Management, enable Use Hardlinks
instead of Copy in both applications.
Hardlinks apply to torrent imports so the original can remain available for
seeding. Usenet imports should use atomic moves from
/data/usenet/complete/movies or /data/usenet/complete/tv into the media
library because those paths are on the same filesystem.
In Prowlarr, assign indexers to an app profile with RSS, automatic search, and
interactive search enabled. The built-in Standard profile provides all
three. An Automatic Search profile has RSS disabled and causes Radarr and
Sonarr to report that no RSS-enabled indexers are available. After changing an
indexer profile, use Settings > Apps > Sync App Indexers and confirm the
synchronized indexers have RSS enabled.
Network Requirements
- The host needs
/dev/net/tunfor Gluetun. - Download clients require outbound VPN access.
- Caddy or trusted clients need access to the published application ports.
Troubleshooting
Use the deployment guide for storage and permissions and the troubleshooting guide for stack issues.
Useful Commands
docker compose -f docker-compose.arr-stack.yml logs --tail=100
docker compose -f docker-compose.arr-stack.yml restart <service>
Backup
Back up DOCKERCONFDIR. Shared media and downloads live in DOCKERSTORAGEDIR.