- 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
477 lines
17 KiB
Markdown
477 lines
17 KiB
Markdown
# 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/`:
|
||
|
||
- `DOCKERCONFDIR` stores application configs on local disk.
|
||
- `DOCKERSTORAGEDIR` stores shared downloads and media.
|
||
|
||
The default paths are `/opt/arr-stack/appdata` and `/mnt/unas/arr_data`. Complete
|
||
[the storage deployment guide](../../docs/arr-stack/deployment.md) 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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
/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`:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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):
|
||
|
||
```bash
|
||
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:
|
||
|
||
```dotenv
|
||
DOCKERSTORAGEDIR=/mnt/unas/arr_data
|
||
```
|
||
|
||
`*arr_data-mount` expands to:
|
||
|
||
```yaml
|
||
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:
|
||
|
||
```yaml
|
||
# *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](https://trash-guides.info/File-and-Folder-Structure/How-to-set-up/Docker/)
|
||
and keeps download and library paths on one filesystem for
|
||
[hardlinks and atomic moves](https://trash-guides.info/File-and-Folder-Structure/Hardlinks-and-Instant-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 qbittorrent` to 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](../../docs/nvidia/proxmox-nvidia-lxc.md).
|
||
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:
|
||
|
||
```bash
|
||
docker stats --no-stream
|
||
free -h
|
||
df -h /
|
||
```
|
||
|
||
Increase LXC RAM if usage regularly approaches the allocation or swap usage
|
||
continues growing.
|
||
|
||
## Installation
|
||
|
||
1. Create `.env` if it does not exist:
|
||
|
||
```bash
|
||
cp -n .env.sample .env
|
||
```
|
||
|
||
2. Set the Arr Stack values in `.env`:
|
||
|
||
```dotenv
|
||
# 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=True
|
||
```
|
||
|
||
`DOCKERCONFDIR` must be local LXC storage. `DOCKERSTORAGEDIR` must be the
|
||
mounted shared-data path and must contain `ARR_DATA_MARKER`.
|
||
|
||
Obtain the WireGuard values and forwarded port from AirVPN. Leave
|
||
`FIREWALL_VPN_INPUT_PORTS` empty if no inbound forwarded port is configured.
|
||
`SERVER_COUNTRIES` may also remain empty.
|
||
|
||
**AirVPN preshared key warning:** AirVPN generates a unique preshared key for
|
||
each device-server combination. The built-in `airvpn` Gluetun 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, use
|
||
`VPN_SERVICE_PROVIDER=custom` and pin a specific server instead:
|
||
|
||
```dotenv
|
||
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 them
|
||
```
|
||
|
||
All four WireGuard values (`PrivateKey`, `PresharedKey`, `Address`, `PublicKey`,
|
||
and `Endpoint`) come from the WireGuard config file downloaded from the AirVPN
|
||
client area for a specific server. The `WIREGUARD_ENDPOINT_IP` is the resolved
|
||
IP address of the server hostname in the `Endpoint` field (use `dig +short
|
||
<hostname>` to resolve it). Leave `SERVER_COUNTRIES` empty 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=True` until its actions have been reviewed; change
|
||
it to `False` only when ready to enable queue cleanup.
|
||
|
||
3. Create config directories, verify shared storage, and render the initial
|
||
Compose file:
|
||
|
||
```bash
|
||
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 .env
|
||
```
|
||
|
||
Do not render `recyclarr.yml` yet. Empty API keys produce an unusable
|
||
Recyclarr configuration.
|
||
|
||
4. Validate Compose and start only the bootstrap services:
|
||
|
||
```bash
|
||
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 seerr
|
||
```
|
||
|
||
This intentionally leaves Recyclarr, Unpackerr, and Decluttarr stopped until
|
||
their API credentials are available.
|
||
|
||
5. Verify the bootstrap services and VPN:
|
||
|
||
```bash
|
||
docker compose -f docker-compose.arr-stack.yml ps
|
||
docker exec gluetun wget -qO- https://ipinfo.io/ip
|
||
```
|
||
|
||
6. 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`:
|
||
|
||
```dotenv
|
||
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>
|
||
```
|
||
|
||
7. Render the final Compose file and Recyclarr configuration:
|
||
|
||
```bash
|
||
./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
|
||
```
|
||
|
||
The rendered Recyclarr configuration uses Docker service URLs
|
||
`http://sonarr:8989` and `http://radarr:7878`. Do not replace them with
|
||
`localhost`; Recyclarr runs in its own container.
|
||
|
||
8. Start Recyclarr and preview its TRaSH Guides changes:
|
||
|
||
```bash
|
||
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 --preview
|
||
```
|
||
|
||
Review the preview before applying it. The repository template creates the
|
||
documented Sonarr and Radarr quality profiles, quality definitions, custom
|
||
formats, and scores.
|
||
|
||
9. Apply Recyclarr and start the remaining services:
|
||
|
||
```bash
|
||
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 ps
|
||
```
|
||
|
||
See [Recyclarr Configuration Guide](../../docs/arr-stack/recyclarr.md) 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:
|
||
|
||
```bash
|
||
./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/tun` for Gluetun.
|
||
- Download clients require outbound VPN access.
|
||
- Caddy or trusted clients need access to the published application ports.
|
||
|
||
## Troubleshooting
|
||
|
||
Use [the deployment guide](../../docs/arr-stack/deployment.md) for storage and permissions
|
||
and [the troubleshooting guide](../../docs/troubleshooting.md) for stack issues.
|
||
|
||
## Useful Commands
|
||
|
||
```bash
|
||
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`.
|