The three Arr posts in Lab Notes documented how this stack evolved: service ownership, a smaller VPN failure domain, defense in depth around untrusted downloads, and stack-wide health invariants.
This is the version meant to be followed.
The goal is to start with a Linux host that already has Docker Engine and the Docker Compose plugin, build the stack in a deliberate order, and end with a deployment whose important assumptions can be tested.
The worked example uses Gluetun, ProtonVPN over OpenVPN, qBittorrent, Jackett, FlareSolverr, Sonarr, Radarr, Recyclarr, Cleanuparr, and Tdarr.
Scope: The values below are sanitized. Replace placeholders and host paths for your environment. Do not publish private addresses, credentials, API keys, VPN account data, or real media names.
Final topology
Internet
|
VPN provider
|
Gluetun
VPN / firewall / tun0
|
+--------------+--------------+
| | |
qBittorrent Jackett FlareSolverr
Docker project network
├── gluetun
├── sonarr -> gluetun:8080 -> qBittorrent
├── radarr -> gluetun:8080 -> qBittorrent
├── recyclarr -> sonarr:8989 / radarr:7878
└── cleanuparr -> sonarr / radarr / gluetun
Separate bridge
└── tdarr
├── /media read-only
└── /temp writable
Only qBittorrent, Jackett, and FlareSolverr share Gluetun’s network namespace. Sonarr, Radarr, Recyclarr, and Cleanuparr stay on the normal Compose network so the control plane remains available when the VPN itself is unhealthy.
Docker’s service-name DNS is what makes that split practical: Sonarr and Radarr can reach services inside the Gluetun namespace through gluetun:<port> rather than a changing container IP.
Prerequisites
This guide assumes:
Linux host
Docker Engine
Docker Compose plugin
curl
jq
filesystem with hardlink support
VPN provider supported by Gluetun
Check Docker:
docker version
docker compose version
The example is ProtonVPN-specific where noted. If you use another provider, keep the architecture but use that provider’s current Gluetun settings.
Step 1: build the storage model first
Hardlinks require the completed download and final media library to live on the same filesystem.
Use one canonical container-visible tree:
/srv/media-stack
├── torrents
│ ├── incomplete
│ ├── tv
│ └── movies
└── media
├── tv
└── movies
Keep application state separate:
/srv/arr
├── appdata
├── secrets
├── .env
└── docker-compose.yml
Create it:
sudo mkdir -p \
/srv/arr/appdata/{gluetun,qbittorrent,jackett,sonarr,radarr,recyclarr,cleanuparr,tdarr/{server,configs,logs}} \
/srv/arr/secrets \
/srv/media-stack/torrents/{incomplete,tv,movies} \
/srv/media-stack/media/{tv,movies} \
/srv/media-stack/.tdarr-temp
sudo chown -R "$(id -u):$(id -g)" \
/srv/arr \
/srv/media-stack
chmod 700 /srv/arr/secrets
Verify the data trees are on the same filesystem:
stat -c 'device=%d path=%n' \
/srv/media-stack/torrents \
/srv/media-stack/media
The device IDs must match.
If they do not, fix the storage design before configuring Sonarr or Radarr.
Step 2: create host settings
Create /srv/arr/.env:
cd /srv/arr
cat > .env <<'EOF'
ARR_BIND_IP=127.0.0.1
PUID=1000
PGID=1000
TZ=Etc/UTC
APPDATA_ROOT=/srv/arr/appdata
DATA_ROOT=/srv/media-stack
VPN_COUNTRY=United States
EOF
chmod 600 .env
Set the UID/GID to the account that should own the files:
id -u
id -g
ARR_BIND_IP=127.0.0.1 is deliberate. If direct LAN access is required, use the host’s trusted management address instead of 0.0.0.0.
Step 3: create credentials as files
For ProtonVPN port forwarding over OpenVPN, Gluetun’s current provider documentation requires the OpenVPN username to end in +pmp.
cd /srv/arr
read -r -p 'Proton OpenVPN username (include +pmp): ' SECRET
printf '%s' "$SECRET" > secrets/openvpn_user
unset SECRET
read -r -s -p 'Proton OpenVPN password: ' SECRET
echo
printf '%s' "$SECRET" > secrets/openvpn_password
unset SECRET
read -r -p 'qBittorrent WebUI username: ' SECRET
printf '%s' "$SECRET" > secrets/qbit_username
unset SECRET
read -r -s -p 'qBittorrent WebUI password: ' SECRET
echo
printf '%s' "$SECRET" > secrets/qbit_password
unset SECRET
chmod 600 secrets/*
grep -q '+pmp$' secrets/openvpn_user \
&& echo 'PASS: Proton username has +pmp' \
|| echo 'FAIL: Proton username is missing +pmp'
If you use a different VPN provider, do not copy the +pmp requirement; use that provider’s current Gluetun instructions instead.
Compose file-backed secrets keep credentials out of the YAML, but the files are still plaintext on the host. Protect and back them up accordingly.
Step 4: install the authenticated qBittorrent port hook
The invariant we want is:
VPN provider forwarded port
==
qBittorrent listen port
A Docker 6881:6881 mapping does not create a VPN-provider port forward.
Gluetun can run commands when provider-side port forwarding comes up or goes down. The following adapter updates qBittorrent through its authenticated Web API.
This implementation assumes qBittorrent 5.2.0 or newer.
Create /srv/arr/appdata/gluetun/qbit-port.sh:
cat > /srv/arr/appdata/gluetun/qbit-port.sh <<'EOF'
#!/bin/sh
set -u
ACTION="${1:-}"
PORT="${2:-}"
VPN_IFACE="${3:-tun0}"
QBIT_URL="http://127.0.0.1:8080"
USER_FILE="/run/secrets/qbit_username"
PASS_FILE="/run/secrets/qbit_password"
log() {
printf '%s\n' "$*"
}
load_auth() {
if [ ! -r "$USER_FILE" ] || [ ! -r "$PASS_FILE" ]; then
log "ERROR: qBittorrent credential secrets unavailable"
return 1
fi
USER="$(cat "$USER_FILE")"
PASS="$(cat "$PASS_FILE")"
AUTH="$(
printf '%s:%s' "$USER" "$PASS" |
base64 |
tr -d '\n'
)"
unset USER PASS
}
wait_for_webui() {
attempt=1
while [ "$attempt" -le 60 ]; do
RESPONSE="$(
wget -S -O /dev/null \
--header="Authorization: Basic $AUTH" \
--header="Referer: $QBIT_URL" \
"$QBIT_URL/api/v2/app/version" \
2>&1 || true
)"
if printf '%s\n' "$RESPONSE" |
grep -qE 'HTTP/1\.[01] 200'; then
return 0
fi
sleep 1
attempt=$((attempt + 1))
done
return 1
}
set_preferences() {
JSON="$1"
RESPONSE="$(
wget -S -O /dev/null \
--header="Authorization: Basic $AUTH" \
--header="Referer: $QBIT_URL" \
--post-data="json=${JSON}" \
"$QBIT_URL/api/v2/app/setPreferences" \
2>&1 || true
)"
if printf '%s\n' "$RESPONSE" |
grep -qE 'HTTP/1\.[01] 200'; then
return 0
fi
log "ERROR: qBittorrent setPreferences request failed"
printf '%s\n' "$RESPONSE" |
grep -E 'HTTP/|ERROR|Forbidden|Unauthorized' ||
true
return 1
}
get_preferences() {
wget -qO- \
--header="Authorization: Basic $AUTH" \
--header="Referer: $QBIT_URL" \
"$QBIT_URL/api/v2/app/preferences"
}
set_vpn_port() {
WANTED="$1"
IFACE="$2"
case "$WANTED" in
''|*[!0-9]*)
log "ERROR: invalid port: $WANTED"
return 1
;;
esac
if [ "$WANTED" -lt 1 ] || [ "$WANTED" -gt 65535 ]; then
log "ERROR: port out of range: $WANTED"
return 1
fi
load_auth || return 1
if ! wait_for_webui; then
log "ERROR: qBittorrent WebUI did not become available"
return 1
fi
JSON="{\"listen_port\":${WANTED},\"current_network_interface\":\"${IFACE}\",\"random_port\":false,\"upnp\":false}"
log "Setting qBittorrent listen port to $WANTED on $IFACE"
set_preferences "$JSON" || return 1
PREFS="$(get_preferences 2>/dev/null || true)"
ACTUAL="$(
printf '%s\n' "$PREFS" |
sed -n 's/.*"listen_port":\([0-9][0-9]*\).*/\1/p'
)"
if [ "$ACTUAL" != "$WANTED" ]; then
log "ERROR: requested=$WANTED actual=${ACTUAL:-unknown}"
return 1
fi
log "qBittorrent listen port verified: $ACTUAL"
}
reset_vpn_port() {
load_auth || return 1
if ! wait_for_webui; then
log "qBittorrent WebUI unavailable during teardown"
return 0
fi
JSON='{"listen_port":0,"current_network_interface":"lo","random_port":false,"upnp":false}'
log "Resetting qBittorrent peer listener"
set_preferences "$JSON" ||
log "WARNING: qBittorrent listener reset failed"
return 0
}
case "$ACTION" in
up|set)
[ -n "$PORT" ] || {
log "ERROR: usage: $0 {up|set} PORT [VPN_INTERFACE]"
exit 2
}
set_vpn_port "$PORT" "$VPN_IFACE"
;;
down)
reset_vpn_port
;;
*)
log "ERROR: usage: $0 {up|set} PORT [VPN_INTERFACE] | down"
exit 2
;;
esac
EOF
chmod 755 /srv/arr/appdata/gluetun/qbit-port.sh
sh -n /srv/arr/appdata/gluetun/qbit-port.sh &&
echo 'PASS: qbit-port.sh syntax'
Do not enable qBittorrent’s localhost-authentication bypass. qBittorrent, Jackett, and FlareSolverr share Gluetun’s namespace and should be treated as mutually adjacent services.
Step 5: create the Compose file
Create /srv/arr/docker-compose.yml:
services:
gluetun:
image: qmcgaw/gluetun
restart: unless-stopped
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
VPN_SERVICE_PROVIDER: protonvpn
VPN_TYPE: openvpn
SERVER_COUNTRIES: "${VPN_COUNTRY:-United States}"
OPENVPN_USER_SECRETFILE: /run/secrets/openvpn_user
OPENVPN_PASSWORD_SECRETFILE: /run/secrets/openvpn_password
VPN_PORT_FORWARDING: "on"
VPN_PORT_FORWARDING_UP_COMMAND: >-
/bin/sh -c '/gluetun/qbit-port.sh up "{{PORT}}" tun0'
VPN_PORT_FORWARDING_DOWN_COMMAND: >-
/bin/sh -c '/gluetun/qbit-port.sh down'
FIREWALL: "on"
FIREWALL_INPUT_PORTS: 8080,9117
VPN_INTERFACE: tun0
IPV6: "off"
secrets:
- openvpn_user
- openvpn_password
- qbit_username
- qbit_password
security_opt:
- no-new-privileges:true
ports:
- "${ARR_BIND_IP:-127.0.0.1}:8080:8080"
- "${ARR_BIND_IP:-127.0.0.1}:9117:9117"
volumes:
- "${APPDATA_ROOT}/gluetun:/gluetun"
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
restart: unless-stopped
network_mode: "service:gluetun"
depends_on:
gluetun:
condition: service_healthy
environment:
PUID: "${PUID:-1000}"
PGID: "${PGID:-1000}"
TZ: "${TZ:-Etc/UTC}"
WEBUI_PORT: "8080"
volumes:
- "${APPDATA_ROOT}/qbittorrent:/config"
- "${DATA_ROOT}:/data"
jackett:
image: lscr.io/linuxserver/jackett:latest
restart: unless-stopped
network_mode: "service:gluetun"
depends_on:
gluetun:
condition: service_healthy
environment:
PUID: "${PUID:-1000}"
PGID: "${PGID:-1000}"
TZ: "${TZ:-Etc/UTC}"
AUTO_UPDATE: "true"
RUN_OPTS: ""
volumes:
- "${APPDATA_ROOT}/jackett:/config"
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
restart: unless-stopped
network_mode: "service:gluetun"
depends_on:
gluetun:
condition: service_healthy
environment:
LOG_LEVEL: info
LOG_HTML: "false"
CAPTCHA_SOLVER: none
TZ: "${TZ:-Etc/UTC}"
sonarr:
image: lscr.io/linuxserver/sonarr:latest
restart: unless-stopped
environment:
PUID: "${PUID:-1000}"
PGID: "${PGID:-1000}"
UMASK: "002"
TZ: "${TZ:-Etc/UTC}"
ports:
- "${ARR_BIND_IP:-127.0.0.1}:8989:8989"
volumes:
- "${APPDATA_ROOT}/sonarr:/config"
- "${DATA_ROOT}:/data"
radarr:
image: lscr.io/linuxserver/radarr:latest
restart: unless-stopped
environment:
PUID: "${PUID:-1000}"
PGID: "${PGID:-1000}"
UMASK: "002"
TZ: "${TZ:-Etc/UTC}"
ports:
- "${ARR_BIND_IP:-127.0.0.1}:7878:7878"
volumes:
- "${APPDATA_ROOT}/radarr:/config"
- "${DATA_ROOT}:/data"
recyclarr:
image: ghcr.io/recyclarr/recyclarr:8
restart: unless-stopped
user: "${PUID:-1000}:${PGID:-1000}"
environment:
TZ: "${TZ:-Etc/UTC}"
CRON_SCHEDULE: "@daily"
volumes:
- "${APPDATA_ROOT}/recyclarr:/config"
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cleanuparr:
image: ghcr.io/cleanuparr/cleanuparr:latest
restart: unless-stopped
environment:
PORT: "11011"
BIND_ADDRESS: "0.0.0.0"
BASE_PATH: ""
PUID: "${PUID:-1000}"
PGID: "${PGID:-1000}"
UMASK: "022"
TZ: "${TZ:-Etc/UTC}"
ports:
- "${ARR_BIND_IP:-127.0.0.1}:11011:11011"
volumes:
- "${APPDATA_ROOT}/cleanuparr:/config"
security_opt:
- no-new-privileges:true
healthcheck:
test:
- CMD
- curl
- -f
- http://127.0.0.1:11011/health
interval: 30s
timeout: 10s
start_period: 30s
retries: 3
tdarr:
image: ghcr.io/haveagitgat/tdarr:2.81.01
restart: unless-stopped
network_mode: bridge
ports:
- "${ARR_BIND_IP:-127.0.0.1}:8265:8265"
env_file:
- ./secrets/tdarr-auth.env
environment:
TZ: "${TZ:-Etc/UTC}"
PUID: "${PUID:-1000}"
PGID: "${PGID:-1000}"
UMASK_SET: "002"
serverIP: "0.0.0.0"
serverPort: "8266"
webUIPort: "8265"
internalNode: "true"
inContainer: "true"
serverURL: "http://127.0.0.1:8266"
nodeName: "arr-health"
ffmpegVersion: "7"
auth: "true"
openBrowser: "false"
maxLogSizeMB: "10"
transcodegpuWorkers: "0"
transcodecpuWorkers: "0"
healthcheckgpuWorkers: "0"
healthcheckcpuWorkers: "1"
volumes:
- "${APPDATA_ROOT}/tdarr/server:/app/server"
- "${APPDATA_ROOT}/tdarr/configs:/app/configs"
- "${APPDATA_ROOT}/tdarr/logs:/app/logs"
- "${DATA_ROOT}/media:/media:ro"
- "${DATA_ROOT}/.tdarr-temp:/temp"
security_opt:
- no-new-privileges:true
secrets:
openvpn_user:
file: ./secrets/openvpn_user
openvpn_password:
file: ./secrets/openvpn_password
qbit_username:
file: ./secrets/qbit_username
qbit_password:
file: ./secrets/qbit_password
Do not add networks: to a service using network_mode: "service:gluetun"; those are mutually exclusive Compose networking models.
The baseline also leaves Gluetun’s health server at its loopback-only default (127.0.0.1:9999). If another container genuinely needs to query that endpoint, explicitly set HEALTH_SERVER_ADDRESS=0.0.0.0:9999 and add 9999 to FIREWALL_INPUT_PORTS; otherwise do not open an unused internal port.
Several images above use moving latest tags because that matches their normal upstream release channel. For a controlled deployment, record the versions or digests you actually validate and update them intentionally rather than blindly auto-updating. The Tdarr tag shown here is the version this health-check workflow was validated against, not a claim that it is the newest release.
Step 6: create Tdarr’s internal API secret
Tdarr UI authentication and Node authentication are separate pieces.
Generate a private key for the internal Node:
cd /srv/arr
TDARR_KEY="tapi_$(openssl rand -hex 24)"
umask 077
cat > secrets/tdarr-auth.env <<EOF
seededApiKey=$TDARR_KEY
apiKey=$TDARR_KEY
EOF
unset TDARR_KEY
Only host port 8265 is published.
Port 8266 is intentionally internal:
Tdarr Web UI host -> 8265
Tdarr Node API 127.0.0.1:8266 inside container
Step 7: validate before starting
cd /srv/arr
sh -n appdata/gluetun/qbit-port.sh &&
echo 'PASS: qBit hook'
docker compose config -q &&
echo 'PASS: Compose'
docker compose config --services
Expected service set:
gluetun
qbittorrent
jackett
flaresolverr
sonarr
radarr
recyclarr
cleanuparr
tdarr
Make sure there is no fixed peer-port mapping:
grep -n '6881' docker-compose.yml ||
echo 'PASS: no fixed 6881 mapping'
Step 8: bring up Gluetun first
docker compose up -d gluetun
GLUE="$(docker compose ps -q gluetun)"
Check health:
docker inspect "$GLUE" \
--format 'status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}n/a{{end}}'
Check VPN IPv4:
docker exec "$GLUE" \
wget -qO- https://api.ipify.org
echo
That uses a third-party IP-echo service only for diagnostics.
If IPv6 is intentionally disabled:
docker exec "$GLUE" sh -c '
timeout 5 wget -6 -qO- https://api64.ipify.org &&
echo "FAIL: unexpected IPv6 Internet path" ||
echo "PASS: no usable IPv6 Internet path"
'
Read the provider-forwarded port:
docker exec "$GLUE" \
cat /tmp/gluetun/forwarded_port
Current Gluetun documentation still supports /tmp/gluetun/forwarded_port, but marks that status file for deprecation in the v4.0.0 release. The actual qBittorrent synchronization in this guide uses Gluetun’s up/down hooks; the file is used here only for bootstrap and verification.
Because qBittorrent has not been started yet, the first Gluetun-only boot may also log that the qBittorrent hook could not reach the WebUI. Step 9 starts qBittorrent and replays the hook manually after authentication is configured.
Do not continue until the VPN path and forwarding state are correct.
Step 9: bootstrap qBittorrent
docker compose up -d qbittorrent
The Web UI is exposed by Gluetun:
http://<ARR_BIND_IP>:8080
For a fresh installation, inspect qBittorrent logs for the temporary WebUI credential if needed:
docker compose logs \
--since 5m \
--no-color \
qbittorrent
Sign in and set the username/password to match:
secrets/qbit_username
secrets/qbit_password
Keep authentication enabled and do not enable localhost bypass.
Then run the hook once manually:
PF="$(
docker exec "$GLUE" \
cat /tmp/gluetun/forwarded_port
)"
docker exec "$GLUE" \
/gluetun/qbit-port.sh up "$PF" tun0
Step 10: prove the torrent transport
Resolve qBittorrent:
QBIT="$(docker compose ps -q qbittorrent)"
Check live TCP/UDP listeners:
docker exec "$QBIT" sh -c '
netstat -lntup 2>/dev/null ||
ss -lntup 2>/dev/null
' |
grep ":${PF}"
Then query preferences through the authenticated API:
QBIT_BIND="$(
sed -n 's/^ARR_BIND_IP=//p' .env |
head -n 1
)"
QBIT_URL="http://${QBIT_BIND:-127.0.0.1}:8080"
QUSER="$(cat secrets/qbit_username)"
QPASS="$(cat secrets/qbit_password)"
COOKIE="$(mktemp)"
trap 'rm -f "$COOKIE"' EXIT
curl -fsS \
-c "$COOKIE" \
-H "Referer: $QBIT_URL" \
--data-urlencode "username=$QUSER" \
--data-urlencode "password=$QPASS" \
"$QBIT_URL/api/v2/auth/login" \
>/dev/null
unset QUSER QPASS
curl -fsS \
-b "$COOKIE" \
-H "Referer: $QBIT_URL" \
"$QBIT_URL/api/v2/app/preferences" |
jq '{
listen_port,
current_network_interface,
random_port,
upnp
}'
Required invariants:
listen_port == provider forwarded port
current_network_interface == tun0
random_port == false
upnp == false
The Web UI working does not prove these things.
Step 11: configure canonical paths
Use:
qBittorrent default:
/data/torrents
incomplete:
/data/torrents/incomplete
Sonarr category:
/data/torrents/tv
Radarr category:
/data/torrents/movies
Sonarr root:
/data/media/tv
Radarr root:
/data/media/movies
qBittorrent, Sonarr, and Radarr all mount the same host filesystem as /data.
That is the important part.
Step 12: add Sonarr and Radarr
docker compose up -d sonarr radarr
Open:
Sonarr http://<ARR_BIND_IP>:8989
Radarr http://<ARR_BIND_IP>:7878
Enable authentication before broadening access beyond loopback.
Configure qBittorrent as:
URL: http://gluetun:8080
Use the matching qBittorrent category for each application.
Do not use the host IP for container-to-container traffic when Docker DNS already supplies the intended path.
Step 13: add Jackett and FlareSolverr
docker compose up -d jackett flaresolverr
Jackett:
http://<ARR_BIND_IP>:9117
FlareSolverr has no host-published port.
Because Jackett and FlareSolverr share Gluetun’s namespace, Jackett can use:
http://127.0.0.1:8191
for compatible FlareSolverr integration.
Sonarr/Radarr reach Jackett through:
http://gluetun:9117
Test every indexer independently.
Step 14: prove the network split
for svc in \
gluetun \
qbittorrent \
jackett \
flaresolverr \
sonarr \
radarr \
recyclarr \
cleanuparr \
tdarr
do
cid="$(docker compose ps -q "$svc")"
printf '%-14s ' "$svc"
docker inspect "$cid" \
--format 'mode={{.HostConfig.NetworkMode}} {{range $n,$v := .NetworkSettings.Networks}}net={{$n}} ip={{$v.IPAddress}} {{end}}'
done
Expected conceptually:
gluetun project network
sonarr project network
radarr project network
recyclarr project network
cleanuparr project network
qbittorrent container:<gluetun-id>
jackett container:<gluetun-id>
flaresolverr container:<gluetun-id>
tdarr bridge
Check control-plane reachability:
for svc in sonarr radarr; do
echo "=== $svc ==="
docker exec "$(docker compose ps -q "$svc")" sh -c '
curl -sS -o /dev/null \
-w "qbit=%{http_code}\n" \
--max-time 5 \
http://gluetun:8080/
curl -sS -o /dev/null \
-w "jackett=%{http_code}\n" \
--max-time 5 \
http://gluetun:9117/
'
done
An expected redirect or authentication error can still prove the network path is alive. Do not require HTTP 200 when an application intentionally answers with 401, 403, or 3xx.
Step 15: prove one hardlink import
First:
stat -c 'device=%d %n' \
/srv/media-stack/torrents \
/srv/media-stack/media
Then complete one known-good download and let Sonarr or Radarr import it.
Compare the download file and library file:
stat -c \
'device=%d inode=%i links=%h size=%s path=%n' \
/srv/media-stack/torrents/<downloaded-file> \
/srv/media-stack/media/<imported-file>
A real hardlink should have:
same device
same inode
same size
link count >= 2
Do not call the storage design finished until this has been proven once.
Step 16: add Recyclarr
Only do this after ordinary Sonarr/Radarr imports work.
docker compose up -d recyclarr
Create its initial configuration if needed:
docker compose run --rm \
recyclarr \
config create
Use Docker service names in the config:
sonarr:
main:
base_url: http://sonarr:8989
api_key: !secret sonarr
radarr:
main:
base_url: http://radarr:7878
api_key: !secret radarr
Use Recyclarr’s supported secret mechanism for API keys.
Preview before applying:
docker compose exec \
recyclarr \
recyclarr sync --preview --log info
Then verify both that the profiles exist and that the intended movies/series are actually assigned to them.
Step 17: add Cleanuparr
docker compose up -d cleanuparr
Check health:
docker inspect "$(docker compose ps -q cleanuparr)" \
--format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}'
Use these internal targets:
Sonarr:
http://sonarr:8989
Radarr:
http://radarr:7878
qBittorrent:
http://gluetun:8080
Enable automation conservatively:
configure
verify connections
observe
dry-run where available
one narrow live rule
review evidence
expand
Cleanuparr is a queue/download management layer, not an antivirus scanner.
Step 18: configure qBittorrent filename policy
A first-pass blocked list can include:
*.exe
*.sh
*.bat
*.cmd
*.com
*.ps1
*.vbs
*.scr
*.msi
*.lnk
This is filename policy, not malware detection.
After configuring it, query qBittorrent’s preferences and verify the setting is actually present.
More importantly, test the same ingestion path production uses. A torrent manually injected into qBittorrent may not exercise the same workflow as one originated by Sonarr or Radarr.
Step 19: test blocked-content handling with an inert canary
Do not use real malware.
Use synthetic torrent metadata that declares an intentionally blocked filename without distributing malicious payload content.
The full test path should be:
Sonarr/Radarr
|
v
candidate accepted
|
v
qBittorrent receives metadata
|
v
blocked file becomes unwanted
|
v
Arr cannot import
|
v
Cleanuparr sees queue condition
|
+--> strike/remove/block
`--> replacement if configured
A canary rejected by a quality profile or minimum-size rule is still useful evidence, but it proves only that upstream gate.
Step 20: add Tdarr as read-only validation
docker compose up -d tdarr
Open:
http://<ARR_BIND_IP>:8265
Create the initial UI account.
Check host publication:
docker port "$(docker compose ps -q tdarr)"
You should see 8265.
You should not see 8266.
Verify mounts:
docker inspect "$(docker compose ps -q tdarr)" \
--format '{{range .Mounts}}{{println .Destination "RW=" .RW}}{{end}}'
Expected:
/media RW= false
/temp RW= true
This deployment is health-check only:
transcodegpuWorkers=0
transcodecpuWorkers=0
healthcheckgpuWorkers=0
healthcheckcpuWorkers=1
Do not pass a GPU into Tdarr unless you intentionally expand its role.
A successful media health check is not a malware verdict and is not a quarantine gate.
Step 21: harden management access
For every Web UI, answer:
Who can reach it?
How do they authenticate?
Does it need host publication at all?
The Compose example defaults to loopback.
If moving to a management LAN:
- enable application authentication first;
- change
ARR_BIND_IP; - recreate the affected services;
- inspect listeners;
- verify authentication again from a fresh browser session.
Check listeners:
ss -lnt |
grep -E \
':(8080|9117|8989|7878|11011|8265|8266)[[:space:]]'
There should be no host listener for Tdarr 8266.
FlareSolverr and Recyclarr should have no host-published ports.
Do not expose management UIs to the public Internet simply because torrent peer traffic is intentionally reachable through the VPN provider.
Step 22: validate the stack as relationships
docker compose up -d
docker compose ps -a
docker compose config -q
Then check the real invariants.
VPN boundary
Gluetun healthy
only qBit / Jackett / FlareSolverr share Gluetun
VPN IPv4 works
unexpected IPv6 path absent if disabled
forwarded port exists
Torrent transport
qBit API reachable
listen port == forwarded port
interface == tun0
random port disabled
UPnP disabled
TCP listener present
UDP listener present
Control plane
Sonarr / Radarr / Recyclarr / Cleanuparr on project network
Sonarr/Radarr reach qBit at gluetun:8080
Sonarr/Radarr reach Jackett at gluetun:9117
Storage
download and library trees same filesystem
one real hardlink proven
Management
UIs bound only where intended
auth enabled
internal-only ports not published
Tdarr
auth enabled
/media read-only
/temp writable
8266 not published
no GPU
transcode workers zero
health worker enabled
Step 23: keep a compact health script
A minimal local checker can validate service state, topology, the forwarded port, and the Tdarr read-only boundary.
Create /srv/arr/health-lite.sh:
cat > /srv/arr/health-lite.sh <<'EOF'
#!/usr/bin/env bash
set -u
cd "$(dirname "$0")" || exit 1
FAILURES=0
pass() {
printf 'PASS: %s\n' "$*"
}
fail() {
printf 'FAIL: %s\n' "$*" >&2
FAILURES=$((FAILURES + 1))
}
echo '=== COMPOSE ==='
docker compose config -q \
&& pass 'Compose valid' \
|| fail 'Compose invalid'
for svc in \
gluetun qbittorrent jackett flaresolverr \
sonarr radarr recyclarr cleanuparr tdarr
do
cid="$(docker compose ps -q "$svc" 2>/dev/null || true)"
[ -n "$cid" ] || {
fail "$svc missing"
continue
}
state="$(
docker inspect "$cid" \
--format '{{.State.Status}}' \
2>/dev/null || true
)"
[ "$state" = running ] \
&& pass "$svc running" \
|| fail "$svc state=$state"
done
echo
echo '=== GLUETUN ==='
GLUE="$(docker compose ps -q gluetun 2>/dev/null || true)"
if [ -n "$GLUE" ]; then
health="$(
docker inspect "$GLUE" \
--format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}'
)"
[ "$health" = healthy ] \
&& pass 'Gluetun healthy' \
|| fail "Gluetun health=$health"
PF="$(
docker exec "$GLUE" \
cat /tmp/gluetun/forwarded_port \
2>/dev/null || true
)"
case "$PF" in
''|*[!0-9]*)
fail 'forwarded port unavailable'
;;
*)
pass 'forwarded port present'
;;
esac
fi
echo
echo '=== VPN-BOUND SERVICES ==='
for svc in qbittorrent jackett flaresolverr; do
cid="$(docker compose ps -q "$svc" 2>/dev/null || true)"
mode="$(
docker inspect "$cid" \
--format '{{.HostConfig.NetworkMode}}' \
2>/dev/null || true
)"
case "$mode" in
container:*)
pass "$svc shares container namespace"
;;
*)
fail "$svc network mode=$mode"
;;
esac
done
echo
echo '=== TDARR ==='
TDARR="$(docker compose ps -q tdarr 2>/dev/null || true)"
if [ -n "$TDARR" ]; then
MEDIA_RW="$(
docker inspect "$TDARR" \
--format '{{range .Mounts}}{{if eq .Destination "/media"}}{{.RW}}{{end}}{{end}}'
)"
[ "$MEDIA_RW" = false ] \
&& pass 'Tdarr /media read-only' \
|| fail "Tdarr /media RW=$MEDIA_RW"
if docker port "$TDARR" 8266/tcp 2>/dev/null |
grep -q .; then
fail 'Tdarr 8266 is host-published'
else
pass 'Tdarr 8266 not host-published'
fi
fi
echo
echo '=== SUMMARY ==='
printf 'Failures: %d\n' "$FAILURES"
if [ "$FAILURES" -eq 0 ]; then
echo 'ARR STACK HEALTH: PASS'
exit 0
else
echo 'ARR STACK HEALTH: FAIL'
exit 1
fi
EOF
chmod 755 /srv/arr/health-lite.sh
Run:
cd /srv/arr
./health-lite.sh
A fuller watchdog can add authenticated API checks, exact bind validation, capacity thresholds, qBittorrent preference comparison, Recyclarr targets, Cleanuparr policy, and retained failure evidence.
Step 24: test failure modes before trusting alerts
Useful tests:
VPN outage
management binding drift
forwarded-port mismatch detection
Tdarr read-only mount validation
application authentication
Do not deliberately expose a random torrent port to test monitoring.
Do not modify real library content merely to prove the Tdarr mount is read-only.
Test the narrowest safe condition that proves the invariant.
Step 25: alert on state transitions
Polling every minute is fine.
Alerting every minute during the same outage is not.
Use:
previous current action
-------- ------- ------
UNKNOWN/PASS PASS silence
UNKNOWN/PASS FAIL DOWN
FAIL FAIL silence
FAIL PASS RECOVERED
PASS PASS silence
Start with:
detect
record
notify
—not automatic restart.
A local watchdog cannot detect the disappearance of its own host, so add an external dead-man check if host-level liveness matters.
Operational commands
Validate:
cd /srv/arr
docker compose config -q
Status:
docker compose ps -a
Logs:
docker compose logs \
--since 15m \
--no-color \
<service>
Recreate an ordinary service:
docker compose up -d \
--no-deps \
--force-recreate \
<service>
Do not recreate Gluetun by itself. qBittorrent, Jackett, and FlareSolverr share its network namespace. If Gluetun itself must be recreated, recreate that dependency group together:
docker compose up -d \
--force-recreate \
gluetun qbittorrent jackett flaresolverr
Current forwarded port:
GLUE="$(docker compose ps -q gluetun)"
docker exec "$GLUE" \
cat /tmp/gluetun/forwarded_port
Topology:
for svc in \
gluetun qbittorrent jackett flaresolverr \
sonarr radarr recyclarr cleanuparr tdarr
do
cid="$(docker compose ps -q "$svc")"
printf '%-14s ' "$svc"
docker inspect "$cid" \
--format 'mode={{.HostConfig.NetworkMode}} {{range $n,$v := .NetworkSettings.Networks}}net={{$n}} {{end}}'
done
Health:
./health-lite.sh
What a passing stack actually proves
A defensible claim is:
The stack is using the expected VPN and container-network topology, the torrent listener matches the provider forwarding state, management exposure matches the configured boundary, Arr imports can hardlink on the shared filesystem, and the configured post-import structural media check has no reported finding.
That does not mean:
the download is guaranteed malware-free
the media file is safe under every parser
the VPN provider is trustworthy
the container images are bug-free
Tdarr is a quarantine gate
Each layer answers a narrower question.
Build order recap
storage
->
VPN boundary
->
qBittorrent transport
->
Sonarr / Radarr
->
Jackett / FlareSolverr
->
one proven hardlink import
->
Recyclarr
->
Cleanuparr
->
filename policy + inert canary
->
read-only Tdarr
->
health invariants
->
stateful alerting
That order keeps troubleshooting local: each layer is added only after the layer below it has a known-good state.
Related Lab Notes
- Building an Arr Stack: What Each Service Should Own
- Hardening an Arr Stack: Defense in Depth from Torrent to Library
- Operationalizing an Arr Stack: Health Invariants and Stateful Alerting
Those posts explain the design decisions and failure modes. This guide is the implementation.