GUIDES

Build a Hardened Arr Stack with Docker Compose: Gluetun, Hardlinks, and Health Checks

Animated terminal validating an Arr stack from Compose topology through VPN port forwarding, hardlinks, and health invariants.
Build the pipeline in layers, then prove the handoffs instead of trusting green containers.

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.


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:

  1. enable application authentication first;
  2. change ARR_BIND_IP;
  3. recreate the affected services;
  4. inspect listeners;
  5. 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.


Those posts explain the design decisions and failure modes. This guide is the implementation.


Upstream references