Files
2026-07-20 09:23:17 -04:00

56 KiB

tags
tags
Bookstack
Documentation
Notes

ArrStack

Media config documentation

ArrStack Compose file

Directory Structure cd docker mkdir -p ~/arr-stack/{config/{gluetun,qbittorrent,prowlarr,radarr,sonarr},data/{torrents,media/{movies,tv}}} cd ~/arr-stack touch docker-compose.yml .env

# Tom Spark's ARR Stack — Automated Media Server
# https://github.com/loponai/arrstack
#
# Usage:
#   1. Copy .env.example to .env and fill in your VPN credentials
#   2. Run: bash setup-folders.sh
#   3. Run: docker compose up -d
#
# All VPN-protected services (qBittorrent, Prowlarr, FlareSolverr) run
# through Gluetun. If the VPN drops, traffic stops. Zero leaks.
#
# Radarr, Sonarr, Lidarr, Bazarr, Jellyfin, and Seerr do NOT run through
# the VPN — they need direct network access for speed and local connectivity.

networks:
  arrnetwork:
    name: arrnetwork
    ipam:
      config:
        - subnet: 172.39.0.0/24

services:

  # ============================================================
  # GLUETUN — VPN Container (kill switch + tunnel)
  # All VPN-protected services route through this container.
  # Ports for those services are mapped HERE, not on the services themselves.
  # Docs: https://github.com/qdm12/gluetun-wiki
  # ============================================================
    gluetun:
    image: qmcgaw/gluetun:latest
    container_name: gluetun
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    networks:
      arrnetwork:
        ipv4_address: ${IP_GLUETUN}
    ports:
      - 8000:8000     # Gluetun Control Server
      - 8080:8080     # qBittorrent WebUI
      - 6881:6881     # qBittorrent torrenting port
      - 6881:6881/udp
      - 9696:9696     # Prowlarr
      - 8191:8191     # FlareSolverr
    volumes:
      - ./gluetun:/gluetun
    environment:
      - VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
      - VPN_TYPE=${VPN_TYPE}
      # --- WireGuard credentials (most providers) ---
      - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
      - WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
#      - WIREGUARD_PUBLIC_KEY=${WIREGUARD_PUBLIC_KEY}
      - WIREGUARD_PRESHARED_KEY=${WIREGUARD_PRESHARED_KEY}
      # --- OpenVPN credentials (if using OpenVPN instead) ---
#      - OPENVPN_USER=${OPENVPN_USER}
#      - OPENVPN_PASSWORD=${OPENVPN_PASSWORD}
      # --- Server selection ---
      - SERVER_COUNTRIES=${SERVER_COUNTRIES}
      # --- Port forwarding (ProtonVPN, AirVPN, PIA) ---
#      - VPN_PORT_FORWARDING=${VPN_PORT_FORWARDING}
      - FIREWALL_VPN_INPUT_PORTS=${FIREWALL_VPN_INPUT_PORTS}
      # --- General ---
      - TZ=${TZ}
      - BLOCK_MALICIOUS=off
      - HTTP_CONTROL_SERVER_ADDRESS=:8000
      - HTTP_CONTROL_SERVER_LOG=on
      - HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE={"auth":"none"}
    healthcheck:
      test: wget -qO /dev/null http://127.0.0.1:9999 || exit 1
      interval: 20s
      timeout: 10s
      retries: 5
    restart: unless-stopped

  # ============================================================
  # QBITTORRENT — Torrent Client (runs through Gluetun VPN)
  # ALL traffic goes through the VPN tunnel. No direct internet.
  # ============================================================
  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    network_mode: service:gluetun
    depends_on:
      gluetun:
        condition: service_healthy
        restart: true
    labels:
      - deunhealth.restart.on.unhealthy=true
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - UMASK=002
      - TZ=${TZ}
      - WEBUI_PORT=8080
      - TORRENTING_PORT=${FIREWALL_VPN_INPUT_PORTS}
    volumes:
      - ./qbittorrent:/config
      - /data:/data
    healthcheck:
      test: wget -q --spider http://localhost:8080 || exit 1
      interval: 60s
      timeout: 10s
      retries: 3
      start_period: 20s
    restart: unless-stopped

  # ============================================================
  # DEUNHEALTH — Auto-restarts unhealthy containers
  # If qBittorrent loses VPN connection, this restarts it automatically.
  # ============================================================
  deunhealth:
    image: qmcgaw/deunhealth
    container_name: deunhealth
    network_mode: none
    environment:
      - LOG_LEVEL=info
      - HEALTH_SERVER_ADDRESS=127.0.0.1:9999
      - TZ=${TZ}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    restart: always
  # ============================================================
  # PROWLARR — Indexer Manager (runs through Gluetun VPN)
  # Manages torrent/usenet indexers. Syncs to Radarr/Sonarr/Lidarr.
  # ============================================================
  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    network_mode: service:gluetun
    depends_on:
      gluetun:
        condition: service_healthy
        restart: true
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - UMASK=002
      - TZ=${TZ}
    volumes:
      - ./prowlarr:/config
    restart: unless-stopped

  # ============================================================
  # FLARESOLVERR — Cloudflare Bypass (runs through Gluetun VPN)
  # Some indexers use Cloudflare protection. This gets around it.
  # ============================================================
  flaresolverr:
    image: ghcr.io/flaresolverr/flaresolverr:latest
    container_name: flaresolverr
    network_mode: service:gluetun
    depends_on:
      gluetun:
        condition: service_healthy
        restart: true
    environment:
      - LOG_LEVEL=info
      - TZ=${TZ}
    restart: unless-stopped

  # ============================================================
  # RADARR — Movie Manager (NOT behind VPN)
  # Searches via Prowlarr, sends downloads to qBittorrent,
  # renames and hard-links completed files to media folder.
  # ============================================================
  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - UMASK=002
      - TZ=${TZ}
    volumes:
      - ./radarr:/config
      - /data:/data
    ports:
      - 7878:7878
    networks:
      arrnetwork:
        ipv4_address: ${IP_RADARR}
    restart: unless-stopped
  # ============================================================
  # SONARR — TV Show Manager (NOT behind VPN)
  # Same pattern as Radarr but for TV series.
  # ============================================================
  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - UMASK=002
      - TZ=${TZ}
    volumes:
      - ./sonarr:/config
      - /data:/data
    ports:
      - 8989:8989
    networks:
      arrnetwork:
        ipv4_address: ${IP_SONARR}
    restart: unless-stopped

  # ============================================================
  # LIDARR — Music Manager (NOT behind VPN)
  # Optional. Comment out if you don't need music automation.
  # ============================================================
  lidarr:
    image: lscr.io/linuxserver/lidarr:latest
    container_name: lidarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - UMASK=002
      - TZ=${TZ}
    volumes:
      - ./lidarr:/config
      - /data:/data
    ports:
      - 8686:8686
    networks:
      arrnetwork:
        ipv4_address: ${IP_LIDARR}
    restart: unless-stopped

  # ============================================================
  # BAZARR — Subtitle Manager (NOT behind VPN)
  # Automatically downloads subtitles for movies and TV shows.
  # ============================================================
  bazarr:
    image: lscr.io/linuxserver/bazarr:latest
    container_name: bazarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - UMASK=002
      - TZ=${TZ}
    volumes:
      - ./bazarr:/config
      - /data:/data
    ports:
      - 6767:6767
    networks:
      arrnetwork:
        ipv4_address: ${IP_BAZARR}
    restart: unless-stopped
  # ============================================================
  # JELLYFIN — Media Server (NOT behind VPN)
  # Your personal streaming service. Plays movies, TV, music.
  # Needs full bandwidth — never put this behind the VPN.
  # ============================================================
  jellyfin:
    image: lscr.io/linuxserver/jellyfin:latest
    container_name: jellyfin
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - UMASK=002
      - TZ=${TZ}
    volumes:
      - ./jellyfin:/config
      - /data/media:/data/media
    ports:
      - 8096:8096
    # Uncomment the lines below to enable hardware transcoding (Intel Quick Sync / VAAPI).
    # Only works if your system has Intel/AMD integrated graphics (/dev/dri must exist).
    # If you get an error about /dev/dri not found, leave these commented out.
    # devices:
    #   - /dev/dri:/dev/dri
    networks:
      arrnetwork:
        ipv4_address: ${IP_JELLYFIN}
    restart: unless-stopped

  # ============================================================
  # SEERR — Request System (NOT behind VPN)
  # Netflix-like UI for requesting movies and TV shows.
  # Share this with family — they never need to touch Radarr.
  #
  # Seerr is the unified successor to Overseerr and Jellyseerr
  # (merged under seerr-team). Supports Plex, Jellyfin, and Emby.
  #
  # Config uses a NAMED Docker volume (not a bind mount). This is
  # required: Seerr runs as the `node` user (UID 1000) and a
  # bind-mounted host folder is created root-owned, causing a
  # permission-denied crash loop. On Windows/WSL, bind mounts also
  # corrupt the SQLite DB over SMB. Named volumes fix both cases
  # (matches upstream Seerr docs).
  #
  # Migrating from ./jellyseerr or ./seerr bind mount? See README
  # troubleshooting "Migrating Seerr config to a named volume".
  # ============================================================
  seerr:
    image: ghcr.io/seerr-team/seerr:v3.0.1
    init: true
    container_name: seerr
    environment:
      - LOG_LEVEL=info
      - TZ=${TZ}
      - PORT=5055
    volumes:
      - seerr_config:/app/config
    ports:
      - 5055:5055
    healthcheck:
      test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/settings/public || exit 1
      start_period: 20s
      timeout: 3s
      interval: 15s
      retries: 3
    networks:
      arrnetwork:
        ipv4_address: ${IP_SEERR}
    restart: unless-stopped
    
  # ============================================================
  # AUDIO BookShelf — Server (NOT behind VPN)
  # Your personal streaming service. Plays all audiobooks.
  # Needs full bandwidth — never put this behind the VPN.
  # ============================================================
  audiobookshelf:
    image: ghcr.io/advplyr/audiobookshelf:latest
    container_name: audiobookshelf
    ports:
      - 13378:80
    volumes:
      - /data/media/audiobookshelf/books:/audiobooks
      - /data/media/audiobookshelf/podcasts:/podcasts
      - /data/media/audiobookshelf/metadata:/metadata
      - ./audiobookshelf/config:/config
    restart: unless-stopped
    networks:
      arrnetwork:
        ipv4_address: ${IP_AUDIO}

  # ============================================================
  # NAVIDROME — Music Media Server (NOT behind VPN)
  # Your personal streaming service. Plays music.
  # Needs full bandwidth — never put this behind the VPN.
  # ============================================================
  navidrome:
    image: deluan/navidrome:latest
    user: 1000:1000
    container_name: navidrome
    ports:
      - 4533:4533
    restart: unless-stopped
    environment:
      ND_SCANSCHEDULE: "1h"
      ND_LOGLEVEL: info
      ND_SESSIONTIMEOUT: "24h"
    volumes:
      - ./navi/data:/data
      - /data/media/music:/music:ro
    networks:
      arrnetwork:
        ipv4_address: ${IP_NAVI}

volumes:
  seerr_config:

2. Step-by-Step qBittorrent Configuration

Once your stack is running via docker compose up -d, navigate to your qBittorrent interface at http://localhost:8118 or your server's IP address (e.g., [http://192.168.1.50:8118](http://192.168.1.50:8118)).

Step 2a: Log In and Find Credentials

  • Look at your container logs (docker logs qbittorrent) to locate the temporary password generated automatically by linuxserver/qbittorrent for security.

  • Use admin as the username and paste that password.

  • Recommended: Instantly go to Tools > Options > Web UI to change the password to a permanent one.

Step 2b: Configure Connection & Port Forwarding

This ensures your traffic efficiently routes through AirVPN's system:

**1.Open Connection Settings:**Inside Web UI.

Go to Tools in the top navigation bar and select Options. In the sidebar menu that pops up, click on Connection.

**2.Set the Torrenting Port:**Match Gluetun mapping.

Locate Port used for incoming connections and type in exactly: 29261.

**3.Disable UPnP / NAT-PMP:**Security step.

Uncheck the box next to Use UPnP / NAT-PMP port forwarding from my router. Your VPN handles port assignment, so your local router shouldn't intervene.

Step 2c: Bind to the VPN Interface (The Hard Kill Switch)

Binding ensures that if Gluetun ever collapses, leaks, or drops connection, qBittorrent immediately stops downloading or seeding instead of trying to pass data via an unprotected network.

**1.Open Advanced Panel:**Scroll down sidebar.

While still in the Options menu, scroll down the left sidebar panel and select Advanced.

**2.Bind Network Interface:**Target VPN tunnel.

Look for Network interface near the top of the list. Change the dropdown menu from Any interface to exactly tun0.

**3.Bind IP Address:**Optional but safer.

Look right underneath at the Optional IP address to bind to setting. Change it from All addresses to All IPv4 addresses.

**4.Save and Restart:**Apply changes.

Click Save / Apply at the bottom right. Restart your container with docker compose restart qbittorrent to guarantee the network interfaces lock into place cleanly.

.env file

# ============================================================
# Tom Spark's ARR Stack — Environment Configuration
# https://github.com/loponai/arrstack
#
# INSTRUCTIONS:
#   1. Copy this file:  cp .env.example .env
#   2. Fill in your VPN credentials below
#   3. Adjust timezone and user IDs if needed
#   4. Run:  docker compose up -d
# ============================================================

# ============================================================
# SYSTEM SETTINGS
# ============================================================

# Your timezone (list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
TZ=America/New_York

# Your Linux user/group ID. Find yours with: id
# Most systems default to 1000. If yours is different, change these.
PUID=1000
PGID=1000

# ============================================================
# VPN SETTINGS — Pick your provider and fill in credentials
# Full provider list: https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers
#
# IMPORTANT: VPN credentials are NOT your login email/password!
# You need service credentials from your VPN provider's manual
# setup or API section. See the provider examples at the bottom of this file.
# ============================================================

# Your VPN provider (surfshark, nordvpn, protonvpn, airvpn, mullvad, private internet access, windscribe, etc.)
VPN_SERVICE_PROVIDER=airvpn

# Protocol: wireguard (recommended, faster) or openvpn
VPN_TYPE=wireguard

# --- WIREGUARD CREDENTIALS ---
# For Surfshark: go to https://my.surfshark.com/vpn/manual-setup/main → WireGuard
# For other providers: see the examples at the bottom of this file
WIREGUARD_PRIVATE_KEY=eDgf3GPFy2ltgx2RkD/Vx5wKZ4dVi28YbQmSJOrbWmk=
WIREGUARD_ADDRESSES=10.175.23.30
# Only needed for some providers (AirVPN). Leave blank if not required.
WIREGUARD_PUBLIC_KEY=
WIREGUARD_PRESHARED_KEY=+0+hrSdzRUxKDNk1Q37PNmNJ2jsj2EzF45JtbZad4lI=

# --- OPENVPN CREDENTIALS ---
# Only needed if VPN_TYPE=openvpn. Leave blank if using WireGuard.
OPENVPN_USER=
OPENVPN_PASSWORD=

# --- SERVER SELECTION ---
# Pick a country close to you for best speeds
SERVER_COUNTRIES=Canada

# --- PORT FORWARDING ---
# Supported by: ProtonVPN, AirVPN, PIA. Can help with upload speeds and seeding.
# Not required for downloading. Most users don't need this.
# Set to "on" if your provider supports it, leave blank otherwise (Surfshark, NordVPN, etc.).
VPN_PORT_FORWARDING=
# If your provider requires manually specifying a port (e.g. AirVPN):
FIREWALL_VPN_INPUT_PORTS=29261
# ============================================================
# NETWORK — Static IPs for each service
# You shouldn't need to change these unless you have a conflict.
# ============================================================
IP_GLUETUN=172.39.0.2
IP_RADARR=172.39.0.3
IP_SONARR=172.39.0.4
IP_LIDARR=172.39.0.5
IP_BAZARR=172.39.0.6
IP_JELLYFIN=172.39.0.7
IP_SEERR=172.39.0.8
IP_AUDIO=172.39.0.9
IP_NAVI=172.39.0.10

# ============================================================
# PROVIDER-SPECIFIC EXAMPLES
# Uncomment and fill in the section for your VPN provider.
# ============================================================

# --- NORDVPN ---
# 1. Go to: https://my.nordaccount.com/dashboard/nordvpn/manual-configuration/
# 2. Generate a WireGuard private key (NordLynx)
# 3. Paste the private key below
# VPN_SERVICE_PROVIDER=nordvpn
# VPN_TYPE=wireguard
# WIREGUARD_PRIVATE_KEY=your_nordvpn_private_key_here
# WIREGUARD_ADDRESSES=10.5.0.2/16
# SERVER_COUNTRIES=United States

# --- PROTONVPN ---
# 1. Go to: https://account.protonvpn.com/ → Downloads → WireGuard configuration
# 2. Generate a config, open the file, copy the PrivateKey and Address
# 3. Port forwarding is supported on paid plans
# VPN_SERVICE_PROVIDER=protonvpn
# VPN_TYPE=wireguard
# WIREGUARD_PRIVATE_KEY=your_proton_private_key_here
# WIREGUARD_ADDRESSES=10.2.0.2/32
# SERVER_COUNTRIES=United States
# VPN_PORT_FORWARDING=on

# --- SURFSHARK ---
# 1. Go to: https://my.surfshark.com/vpn/manual-setup/main
# 2. Get WireGuard credentials
# VPN_SERVICE_PROVIDER=surfshark
# VPN_TYPE=wireguard
# WIREGUARD_PRIVATE_KEY=your_surfshark_private_key_here
# WIREGUARD_ADDRESSES=10.14.0.2/16
# SERVER_COUNTRIES=United States

# --- AIRVPN ---
# 1. Go to: https://airvpn.org/ → Client Area → Config Generator
# 2. Select Linux → WireGuard → pick a server → Generate
# 3. Copy all keys and the assigned IP
# VPN_SERVICE_PROVIDER=airvpn
# VPN_TYPE=wireguard
# WIREGUARD_PRIVATE_KEY=your_airvpn_private_key_here
# WIREGUARD_PUBLIC_KEY=your_airvpn_public_key_here
# WIREGUARD_PRESHARED_KEY=your_airvpn_preshared_key_here
# WIREGUARD_ADDRESSES=your_assigned_ip/32
# FIREWALL_VPN_INPUT_PORTS=your_forwarded_port
# VPN_PORT_FORWARDING=on

# --- MULLVAD ---
# 1. Go to: https://mullvad.net/en/account → WireGuard configuration
# VPN_SERVICE_PROVIDER=mullvad
# VPN_TYPE=wireguard
# WIREGUARD_PRIVATE_KEY=your_mullvad_private_key_here
# WIREGUARD_ADDRESSES=your_assigned_ip/32
# SERVER_COUNTRIES=United States


README.md

Want the easy, full version? → SparkBox

This is one of my original one-shot scripts. It still works — but these days everything I build goes into **[SparkBox](https://tomsparkbo>

👉 Get it free at tomsparkbox.com

Built by Tom Spark Reviews.


Tom Spark's ARR Stack

One-command automated media server with VPN protection. Sonarr, Radarr, Prowlarr, qBittorrent, Gluetun, Jellyfin, and more.

Full video tutorial: [YouTube Link Coming Soon]

What You Get

ServicePortPurpose
GluetunVPN tunnel with kill switch
qBittorrent8080Torrent client (VPN protected)
Prowlarr9696Indexer manager (VPN protected)
FlareSolverr8191Cloudflare bypass (VPN protected)
Radarr7878Movie automation
Sonarr8989TV show automation
Lidarr8686Music automation
Bazarr6767Subtitle automation
Jellyfin8096Media server / streaming
Seerr5055Netflix-like request UI (Overseerr/Jellyseerr successor)

All download traffic routes through Gluetun's VPN tunnel. If the VPN drops, all traffic stops — zero leaks. The deunhealth container auto-r>

Quick Start

1. Install Docker

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Log out and back in for group change to take effect

2. Clone this repo

git clone https://github.com/loponai/arrstack.git
cd arrstack

3. Create folder structure

sudo bash setup-folders.sh

This creates:

/data/
├── torrents/     ← qBittorrent downloads here
│   ├── movies/
│   ├── tv/
│   └── music/
└── media/        ← Radarr/Sonarr organize files here (Jellyfin reads from here)
    ├── movies/
    ├── tv/
    └── music/

Hard links: Both folders MUST be on the same drive/filesystem. Radarr and Sonarr create hard links (not copies) — the file appears in>

4. Configure your VPN

cp .env.example .env
nano .env

Fill in your VPN provider and credentials. See VPN Setup Guides below.

5. Launch

docker compose up -d

6. Verify everything is working

bash test-stack.sh

This runs a full health check — Docker status, VPN connection, IP leak test, service accessibility, hard link support, and folder permissio>

You can also check manually:

# Check Gluetun's IP (should be VPN, not your real IP)
docker exec gluetun wget -qO- ifconfig.me

# qBittorrent shares Gluetun's network, so the above proves both are tunneled.
docker exec qbittorrent wget -qO- ifconfig.me

# Check health status of all containers
docker ps --format "table {{.Names}}\t{{.Status}}"

7. Configure services

Open each service in your browser at http://YOUR-SERVER-IP:PORT and follow the video tutorial for step-by-step configuration.

Quick reference:

  • qBittorrent (:8080) — Get temp password: docker logs qbittorrent 2>&1 | grep "temporary password"
  • Prowlarr (:9696) — Add indexers, connect to Radarr/Sonarr. If an indexer is blocked by Cloudflare, set up FlareSolverr as a proxy: >
  • Radarr (:7878) — Root folder: /data/media/movies, download client category: movies
  • Sonarr (:8989) — Root folder: /data/media/tv, download client category: tv
  • Jellyfin (:8096) — Add libraries: /data/media/movies, /data/media/tv, /data/media/music. To watch, open `http://YOUR-SERVER-I>
  • Seerr (:5055) — Connect to Jellyfin, Radarr, and Sonarr. Seerr is the unified successor to Overseerr/Jellyseerr. If you previously >

Internal Docker IPs — use these when connecting services to each other (NOT localhost):

IPService
`172.39.0.2`Gluetun (also qBittorrent, Prowlarr, FlareSolverr)
`172.39.0.3`Radarr
`172.39.0.4`Sonarr
`172.39.0.5`Lidarr
`172.39.0.6`Bazarr
`172.39.0.7`Jellyfin
`172.39.0.8`Seerr

These IPs are the same for everyone — they're hardcoded in the docker-compose file.
Common connections:

  • Radarr/Sonarr → Download Client → qBittorrent: host 172.39.0.2, port 8080
  • Prowlarr → Apps → Radarr: server http://172.39.0.3:7878
  • Prowlarr → Apps → Sonarr: server http://172.39.0.4:8989
  • Prowlarr → Apps → Prowlarr Server: http://172.39.0.2:9696
  • Seerr → Radarr: host 172.39.0.3, port 7878
  • Seerr → Sonarr: host 172.39.0.4, port 8989
  • Seerr → Jellyfin: host 172.39.0.7, port 8096

Important Radarr/Sonarr settings:

  • Media Management → Show Advanced → Use Hardlinks instead of Copy → must be ON
  • Media Management → Rename Movies/Episodes → recommended ON

Recommended quality profile (1080p baseline, 4K preferred):

Go to Settings → Profiles and edit or create a profile:

  1. Uncheck everything below 1080p (720p, 480p, etc.)
  2. Check/enable everything from HDTV-1080p up through Bluray-2160p
  3. Set Cutoff to Bluray-1080p — this is the minimum quality Radarr/Sonarr will be happy with
  4. Set Upgrade Until to Bluray-2160p — it will automatically upgrade to 4K if one becomes available

This means it grabs a 1080p release right away so you can start watching, then silently upgrades to 4K later if it finds one.

VPN Setup Guides

The best value for torrenting — cheapest long-term plans, fast WireGuard speeds, and easy setup with Gluetun. [Get Surfshark](https://get.s>

  1. Go to Surfshark Manual Setup
  2. Select WireGuard and get your credentials (private key + address)
  3. In your .env:
VPN_SERVICE_PROVIDER=surfshark
VPN_TYPE=wireguard
WIREGUARD_PRIVATE_KEY=your_key_here
WIREGUARD_ADDRESSES=10.14.0.2/16
SERVER_COUNTRIES=United States

The .env.example file is pre-configured for Surfshark. Just paste your private key and you're good to go.

NordVPN

  1. Go to NordVPN Manual Setup
  2. Select NordLynx (WireGuard) and generate a private key
  3. In your .env:
VPN_SERVICE_PROVIDER=nordvpn
VPN_TYPE=wireguard
WIREGUARD_PRIVATE_KEY=your_key_here
WIREGUARD_ADDRESSES=10.5.0.2/16
SERVER_COUNTRIES=United States

ProtonVPN

  1. Go to ProtonVPN WireGuard Config → Downloads → WireGuard
  2. Generate a config file, open it, copy PrivateKey and Address
  3. In your .env:
VPN_SERVICE_PROVIDER=protonvpn
VPN_TYPE=wireguard
WIREGUARD_PRIVATE_KEY=your_key_here
WIREGUARD_ADDRESSES=10.2.0.2/32
SERVER_COUNTRIES=United States
VPN_PORT_FORWARDING=on

AirVPN

  1. Go to AirVPN Config Generator → Client Area → Config Generator
  2. Select Linux → WireGuard → pick server → Generate
  3. In your .env:
VPN_SERVICE_PROVIDER=airvpn
VPN_TYPE=wireguard
WIREGUARD_PRIVATE_KEY=your_key_here
WIREGUARD_PUBLIC_KEY=server_public_key
WIREGUARD_PRESHARED_KEY=your_preshared_key
WIREGUARD_ADDRESSES=your_ip/32
FIREWALL_VPN_INPUT_PORTS=your_port
VPN_PORT_FORWARDING=on

Other Providers

Gluetun supports 30+ providers. Check the full provider list.

Starting and Stopping

Start the stack:

cd arrstack
docker compose up -d

Stop the stack:

cd arrstack
docker compose down

Check status:

docker ps --format "table {{.Names}}\t{{.Status}}"

Auto-Start After Reboot

All containers are set to restart: unless-stopped, which means they automatically come back once Docker is running. You just need to make>

Linux (dedicated server or VM):

Run this once and you're done:

sudo systemctl enable docker

Windows (running Docker inside WSL):

WSL (Windows Subsystem for Linux) doesn't start Docker automatically when your PC boots. Here's how to fix that:

Step 1: Open your WSL terminal and run this command to edit the WSL config file:

sudo nano /etc/wsl.conf

Step 2: Your file might already have some lines in it (like [boot] or [user]). Look for a [boot] section. If it exists, add the `>

[boot]
command=service docker start

Step 3: Save the file by pressing Ctrl+X, then Y, then Enter.
Step 4 (optional): By default, WSL only starts when you open a terminal. If you want it to start automatically when Windows boots (so y>

  1. Press Win+R on your keyboard
  2. Type shell:startup and press Enter — this opens your Windows Startup folder
  3. Right-click in the folder → New → Text Document
  4. Name it wsl.vbs (make sure it ends in .vbs, not .vbs.txt — if you can't see file extensions, go to View → Show → File name extensi>
  5. Right-click the file → Edit (or Open with Notepad) and paste this:
Set ws = CreateObject("Wscript.Shell")
ws.Run "wsl -d Ubuntu -u root -- service docker start", 0

  1. Save and close

That's it — next time your PC restarts, WSL starts Docker automatically and all your containers come back up on their own. No commands need>

Updating

cd arrstack
docker compose pull
docker compose up -d

Remote Access with Tailscale (Optional)

Want to access Jellyfin, Seerr, or any service from outside your home? Tailscale creates a private network betwee>

On your server:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up

On your phone/laptop/TV:

  1. Install Tailscale from your app store
  2. Sign in with the same account

Access your services from anywhere:

http://YOUR-TAILSCALE-IP:8096    ← Jellyfin
http://YOUR-TAILSCALE-IP:5055    ← Seerr
http://YOUR-TAILSCALE-IP:7878    ← Radarr
http://YOUR-TAILSCALE-IP:8989    ← Sonarr

Find your Tailscale IP with tailscale ip -4 on the server.

Tailscale is free for personal use (up to 100 devices). Everything is encrypted with WireGuard — nobody can see your traffic, not even Tail>

Do NOT expose Jellyfin directly to the internet (no port forwarding on your router). Use Tailscale or a reverse proxy instead. Direct>

Sharing with Family and Friends

Your family and friends only need two things — Seerr to request movies/shows and Jellyfin to watch them. They never see Radarr, Son>

Step 1: Invite them to your Tailscale network

  1. Go to the Tailscale admin console
  2. Click Share on your server's machine
  3. Enter their email — they'll get an invite link

Step 2: They install Tailscale

  1. Download Tailscale on their phone, laptop, or TV from tailscale.com/download
  2. Accept your invite and sign in
    Step 3: They access your services

Give them these two links (replace with your Tailscale IP):

http://YOUR-TAILSCALE-IP:5055    ← Seerr (request movies and shows)
http://YOUR-TAILSCALE-IP:8096    ← Jellyfin (watch everything)

For TVs and phones, they can install the Jellyfin app (available on Roku, Fire TV, Apple TV, Android TV, iOS, Android) and enter yo>

That's it — they request, you automatically download, they watch. No technical knowledge needed on their end.

Troubleshooting

Gluetun unhealthy / won't connect:

  • Double-check VPN credentials in .env — these are NOT your login email/password
  • Try removing the gluetun folder and restarting: rm -rf gluetun && docker compose up -d
  • Check logs: docker logs gluetun

qBittorrent can't connect:

  • Make sure Gluetun is healthy: docker ps (should show "healthy")
  • Check qBit is using VPN: docker exec gluetun wget -qO- ifconfig.me
  • In qBittorrent settings → Advanced → set Network Interface to tun0

Movie or show not downloading:

  • Quality profile too strict — If Radarr/Sonarr can't find a release matching your quality profile, it won't download anything. Go to t>
  • Not enough indexers — Public indexers have limited catalogs. If you only have one or two indexers in Prowlarr, add more (1337x, The P>
  • Not enough seeders — Some torrents just don't have anyone sharing them, especially older or niche content. Check qBittorrent — if the>
  • Indexer blocked by Cloudflare — See the Prowlarr setup note above about setting up FlareSolverr with tags.

Hard links not working (files copying instead):

  • Both /data/torrents and /data/media must be on the same filesystem
  • Check Radarr/Sonarr → Settings → Media Management → "Use Hardlinks" is checked
  • Verify with: ls -i /data/torrents/movies/yourfile and ls -i /data/media/movies/YourMovie/yourfile — inode numbers should match

Permission errors:

  • Run id and make sure PUID/PGID in .env match your user
  • Re-run: sudo chown -R $(id -u):$(id -g) /data

Service won't start — "port already in use":

Another program on your system might be using the same port. This is common with port 5055 (Seerr) but can happen with any service.

  1. Find what's using the port (replace 5055 with the port number from the error):

    Linux:

    sudo ss -tlnp | grep 5055
    
    

    Windows (WSL users) — run in PowerShell:

    netstat -ano | findstr :5055
    
    

    This gives you a PID (process ID). Find the program name:

    Get-Process -Id <PID> | Select-Object ProcessName, Id, Path
    
    
  2. Either stop/disable that program, or change the port in docker-compose.yml to an unused one (e.g. 5056:5055).

  3. If it's a Windows service hogging the port, disable it in an admin PowerShell:

    Stop-Service <ServiceName> -Force
    Set-Service <ServiceName> -StartupType Disabled
    
    
  4. Then recreate the container:

    docker compose up -d --force-recreate <service-name>
    
    

Seerr stuck restarting / crash-looping:

The current docker-compose.yml uses a named volume (seerr_config) for Seerr's config, which fixes both causes of the crash loop. If you>

Root cause (for reference):

  • Linux/macOS: Seerr runs as the node user (UID 1000). A bind-mounted ./seerr folder is created root-owned on first `docker compose>
  • Windows/WSL: Bind mounts go through an SMB share inside Docker Desktop's VM, which doesn't support file locking. Seerr's SQLite DB co>

Named volumes sidestep both problems because Docker creates them with correct ownership inside its own managed storage.

If you still see it after pulling:

  • Check logs: docker logs seerr | tail -30

  • Nuke the volume and start fresh (you'll lose Seerr settings, not media): ```bash docker compose down seerr && docker volume rm arrstack_seerr_config && docker compose up -d seerr

    
    

Migrating Seerr config to a named volume (existing installs):

If you had Seerr working on an older version of this repo with a bind-mounted ./jellyseerr or ./seerr folder and want to keep your sett>

docker compose down seerr
# pick whichever folder you actually have
SRC=./seerr
[ -d ./jellyseerr ] && SRC=./jellyseerr
docker volume create arrstack_seerr_config
docker run --rm -v "$(pwd)/${SRC#./}":/src -v arrstack_seerr_config:/dest alpine sh -c "cp -a /src/. /dest/ && chown -R 1000:1000 /dest"
docker compose up -d seerr

If you don't care about preserving settings, just docker compose up -d seerr — Seerr will start fresh and walk you through setup again.

Can't log into qBittorrent:

  • qBittorrent generates a temporary password every time it starts. Get it with: ```bash docker logs qbittorrent 2>&1 | grep "temporary password"

  • Default username is admin. Once logged in, go to Tools → Options → Web UI and set a permanent password.

Services can't connect to each other (connection refused, timeout):

  • Don't use localhost when connecting services together — that won't work across Docker containers.
  • Use the internal Docker IPs instead:
    • qBittorrent/Prowlarr/FlareSolverr: 172.39.0.2
    • Radarr: 172.39.0.3
    • Sonarr: 172.39.0.4
    • Jellyfin: 172.39.0.7
  • The one exception: Prowlarr → FlareSolverr can use localhost:8191 because they both run through Gluetun and share the same network.

"Root folder does not exist" in Radarr/Sonarr:

  • Make sure you ran sudo bash setup-folders.sh to create the /data directory structure.
  • Double-check the root folder path — it should be /data/media/movies for Radarr and /data/media/tv for Sonarr (not /movies or `/data>

Downloads stuck at "importing" or "waiting to import":

  • This is almost always a permissions issue. Fix it with: ```bash sudo chown -R $(id -u):$(id -g) /data sudo chmod -R 775 /data

  • Make sure PUID/PGID in your .env match your user (check with id).
    Jellyfin library is empty after downloads finish:

  • Make sure your Jellyfin libraries point to the correct paths: /data/media/movies, /data/media/tv, /data/media/music

  • Jellyfin doesn't scan instantly. Go to Dashboard → Libraries → click the ... menu → Scan Library to force a refresh.

  • You can also set up scheduled scans in Dashboard → Scheduled Tasks.

Subtitles not downloading (Bazarr):

  • Bazarr needs to be connected to Radarr and Sonarr: Settings → Radarr / Sonarr → enter the IP (172.39.0.3 / 172.39.0.4) and API key.
  • You also need at least one subtitle provider: Settings → Providers → Add → OpenSubtitles.com is the most popular (free account requir>

Everything works but downloads are slow:

  • Your VPN server might be too far away. Change SERVER_COUNTRIES in your .env to a country closer to you, then restart: ```bash docker compose down && docker compose up -d

  • Check your VPN speed: docker exec gluetun wget -qO- https://speed.cloudflare.com/__down?measId=10000000 > /dev/null — if it's very slow>

Disk space filling up:

  • By default, qBittorrent keeps torrents after Radarr/Sonarr imports them. To auto-clean:
    • In Radarr/Sonarr → Settings → Download Clients → click on qBittorrent → enable Remove Completed
    • This deletes the torrent from qBittorrent after the file has been imported (the hard link in your media folder is kept, so you don't lo>

VPN IP leak — want to make sure your real IP isn't exposed:

# Check the VPN container's IP (should NOT be your real IP)
docker exec gluetun wget -qO- ifconfig.me

# Compare with your real IP (run this outside Docker)
curl -s ifconfig.me

If both IPs are the same, your VPN isn't working — check Gluetun logs with docker logs gluetun.

Credits

Built by Tom Spark following Trash Guides and [Servarr Wiki](https://wiki.se>

Uses Gluetun for VPN, LinuxServer.io container images, and [Seerr](https://gi>

Set up folders Script

#!/bin/bash
# ============================================================
# Tom Spark's ARR Stack — Folder Structure Setup
# https://github.com/loponai/arrstack
#
# Creates the /data directory structure required for hard links
# to work correctly. Run this ONCE before starting the stack.
#
# Usage: sudo bash setup-folders.sh
# ============================================================

set -e

DATA_DIR="/data"

echo ""
echo "=== Tom Spark's ARR Stack — Folder Setup ==="
echo ""
echo "This will create the following structure:"
echo ""
echo "  /data/"
echo "  ├── torrents/"
echo "  │   ├── movies/"
echo "  │   ├── tv/"
echo "  │   └── music/"
echo "  └── media/"
echo "      ├── movies/"
echo "      ├── tv/"
echo "      └── music/"
echo ""

# Check if running as root
if [ "$EUID" -ne 0 ]; then
    echo "ERROR: This script needs sudo to create /data and set permissions."
    echo "Run: sudo bash setup-folders.sh"
    exit 1
fi
# Get the real user (not root) for ownership
REAL_USER=${SUDO_USER:-$USER}
REAL_UID=$(id -u "$REAL_USER")
REAL_GID=$(id -g "$REAL_USER")

echo "Creating folders..."
mkdir -p "$DATA_DIR"/{torrents/{movies,tv,music},media/{movies,tv,music}}

echo "Setting ownership to $REAL_USER ($REAL_UID:$REAL_GID)..."
chown -R "$REAL_UID":"$REAL_GID" "$DATA_DIR"

echo "Setting permissions..."
chmod -R 775 "$DATA_DIR"

echo ""
echo "Done! Folder structure:"
if command -v tree &> /dev/null; then
    tree "$DATA_DIR"
else
    find "$DATA_DIR" -type d | head -20
fi

echo ""
echo "Your PUID=$REAL_UID and PGID=$REAL_GID"
echo "Make sure these match your .env file."
echo ""


Test Stack script

#!/bin/bash
# ============================================================
# Tom Spark's ARR Stack — Health Check & Troubleshooting
# https://github.com/loponai/arrstack
#
# Run this after 'docker compose up -d' to verify everything
# is working correctly. It checks each service, tests VPN
# connectivity, and provides specific fixes for any issues.
#
# Usage: bash test-stack.sh
# ============================================================

set -o pipefail

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'

PASS="${GREEN}✓ PASS${NC}"
FAIL="${RED}✗ FAIL${NC}"
WARN="${YELLOW}! WARN${NC}"
TOTAL_PASS=0
TOTAL_FAIL=0
TOTAL_WARN=0

pass() { echo -e "  ${PASS}  $1"; ((TOTAL_PASS++)); }
fail() { echo -e "  ${FAIL}  $1"; ((TOTAL_FAIL++)); }
warn() { echo -e "  ${WARN}  $1"; ((TOTAL_WARN++)); }
header() { echo -e "\n${CYAN}${BOLD}[$1]${NC}"; }
fix() { echo -e "         ${YELLOW}Fix: $1${NC}"; }

echo ""
echo "========================================="
echo "  Tom Spark's ARR Stack — Health Check"
echo "========================================="
echo ""

# ============================================================
# TEST 1: Docker running?
# ============================================================
header "Docker"
if docker info > /dev/null 2>&1; then
    pass "Docker is running"
else
    fail "Docker is not running"
    fix "Start Docker: sudo systemctl start docker"
    fix "Or install: curl -fsSL https://get.docker.com | sh"
    echo ""
    echo "Cannot continue without Docker. Exiting."
    exit 1
fi

# ============================================================
# TEST 2: .env file exists and has VPN credentials?
# ============================================================
header "Configuration"

if [ -f .env ]; then
    pass ".env file exists"
else
    fail ".env file not found"
    fix "Run: cp .env.example .env && nano .env"
    fix "Then fill in your VPN credentials"
fi

if [ -f .env ]; then
    VPN_KEY=$(grep -E "^WIREGUARD_PRIVATE_KEY=" .env 2>/dev/null | cut -d= -f2)
    VPN_PROVIDER=$(grep -E "^VPN_SERVICE_PROVIDER=" .env 2>/dev/null | cut -d= -f2)

    if [ -n "$VPN_KEY" ] && [ "$VPN_KEY" != "" ]; then
        pass "VPN private key is set (provider: $VPN_PROVIDER)"
    else
        fail "VPN private key is empty"
        fix "Edit .env and paste your WireGuard private key"
        fix "Get it from your VPN provider's manual setup page"
    fi
fi
# ============================================================
# TEST 3: Folder structure exists?
# ============================================================
header "Folder Structure"

ALL_FOLDERS_OK=true
for dir in /data/torrents/movies /data/torrents/tv /data/torrents/music /data/media/movies /data/media/tv /data/media/music; do
    if [ -d "$dir" ]; then
        pass "$dir exists"
    else
        fail "$dir missing"
        ALL_FOLDERS_OK=false
    fi
done

if [ "$ALL_FOLDERS_OK" = false ]; then
    fix "Run: sudo bash setup-folders.sh"
fi

# Check permissions
if [ -d /data ]; then
    OWNER=$(stat -c '%u' /data 2>/dev/null)
    ENV_PUID=$(grep -E "^PUID=" .env 2>/dev/null | cut -d= -f2)
    if [ "$OWNER" = "$ENV_PUID" ] || [ "$OWNER" = "$(id -u)" ]; then
        pass "/data ownership matches PUID ($OWNER)"
    else
        warn "/data owned by $OWNER but PUID is ${ENV_PUID:-1000}"
        fix "Run: sudo chown -R ${ENV_PUID:-1000}:${ENV_PUID:-1000} /data"
    fi
fi
# ============================================================
# TEST 4: Container status
# ============================================================
header "Containers"

EXPECTED_SERVICES="gluetun qbittorrent deunhealth prowlarr flaresolverr radarr sonarr lidarr bazarr jellyfin seerr"

for svc in $EXPECTED_SERVICES; do
    STATUS=$(docker inspect --format '{{.State.Status}}' "$svc" 2>/dev/null)
    HEALTH=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}' "$svc" 2>/dev/null)

    if [ -z "$STATUS" ]; then
        fail "$svc — not found (not created)"
        fix "Run: docker compose up -d"
    elif [ "$STATUS" = "running" ]; then
        if [ "$HEALTH" = "healthy" ]; then
            pass "$svc — running (healthy)"
        elif [ "$HEALTH" = "unhealthy" ]; then
            fail "$svc — running but UNHEALTHY"
            if [ "$svc" = "gluetun" ]; then
                fix "VPN probably can't connect. Check credentials in .env"
                fix "Check logs: docker logs gluetun | tail -20"
                fix "Try: rm -rf gluetun && docker compose up -d gluetun"
            elif [ "$svc" = "qbittorrent" ]; then
                fix "Usually means VPN dropped. Deunhealth should auto-restart it."
                fix "Check: docker logs qbittorrent | tail -20"
            fi
        elif [ "$HEALTH" = "starting" ]; then
            warn "$svc — running (health check starting, wait 30s and rerun)"
        else
            pass "$svc — running"
        fi
    elif [ "$STATUS" = "created" ]; then
        warn "$svc — created but not started"
        if [ "$svc" = "qbittorrent" ] || [ "$svc" = "prowlarr" ] || [ "$svc" = "flaresolverr" ]; then
            fix "Waiting for Gluetun to be healthy. Check Gluetun status first."
            fix "If Gluetun is healthy, try: docker compose up -d $svc"
        elif [ "$svc" = "seerr" ]; then
            fix "Port 5055 may be in use. Check: ss -tlnp | grep 5055"
            fix "Or change the port in docker-compose.yml"
        else
            fix "Try: docker compose up -d $svc"
        fi
            elif [ "$STATUS" = "restarting" ]; then
        fail "$svc — crash-looping (restarting)"
        fix "Check logs: docker logs $svc | tail -30"
        if [ "$svc" = "seerr" ]; then
            fix "Seerr may have a corrupt config. Try: docker compose down seerr && rm -rf seerr && docker compose up -d seerr"
            fix "WSL/Windows users: if it keeps crashing, try a named volume instead of a bind mount"
        else
            fix "Try: docker compose down $svc && docker compose up -d $svc"
        fi
    elif [ "$STATUS" = "exited" ]; then
        fail "$svc — exited (crashed)"
        fix "Check logs: docker logs $svc | tail -30"
        fix "Try restarting: docker compose up -d $svc"
    else
        warn "$svc — status: $STATUS"
    fi
done
# ============================================================
# TEST 5: VPN connectivity
# ============================================================
header "VPN Connection"

GLUETUN_STATUS=$(docker inspect --format '{{.State.Status}}' gluetun 2>/dev/null)
GLUETUN_HEALTH=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' gluetun 2>/dev/null)

if [ "$GLUETUN_STATUS" = "running" ] && [ "$GLUETUN_HEALTH" = "healthy" ]; then
    # Get VPN IP
    VPN_IP=$(docker exec gluetun wget -qO- --timeout=10 ipinfo.io/ip 2>/dev/null)
    if [ -n "$VPN_IP" ]; then
        pass "Gluetun VPN IP: $VPN_IP"

        # Get VPN location
        VPN_LOCATION=$(docker exec gluetun wget -qO- --timeout=10 "ipinfo.io/${VPN_IP}/city" 2>/dev/null)
        VPN_COUNTRY=$(docker exec gluetun wget -qO- --timeout=10 "ipinfo.io/${VPN_IP}/country" 2>/dev/null)
        if [ -n "$VPN_LOCATION" ]; then
            pass "VPN location: $VPN_LOCATION, $VPN_COUNTRY"
        fi
    else
        fail "Gluetun is healthy but can't reach the internet"
        fix "Check logs: docker logs gluetun | tail -20"
    fi

    # Check if qBittorrent is tunneled
    QBIT_STATUS=$(docker inspect --format '{{.State.Status}}' qbittorrent 2>/dev/null)
    if [ "$QBIT_STATUS" = "running" ]; then
        QBIT_IP=$(docker exec qbittorrent wget -qO- --timeout=10 ipinfo.io/ip 2>/dev/null)
        if [ "$QBIT_IP" = "$VPN_IP" ]; then
            pass "qBittorrent tunneled through VPN ($QBIT_IP)"
        elif [ -n "$QBIT_IP" ]; then
            fail "qBittorrent IP ($QBIT_IP) doesn't match VPN IP ($VPN_IP)!"
            fix "This should not happen. Check network_mode in docker-compose.yml"
        else
            warn "Could not check qBittorrent IP (container may still be starting)"
        fi
    fi
    # Check if Prowlarr is tunneled
    PROWLARR_STATUS=$(docker inspect --format '{{.State.Status}}' prowlarr 2>/dev/null)
    if [ "$PROWLARR_STATUS" = "running" ]; then
        PROWLARR_IP=$(docker exec prowlarr wget -qO- --timeout=10 ipinfo.io/ip 2>/dev/null)
        if [ "$PROWLARR_IP" = "$VPN_IP" ]; then
            pass "Prowlarr tunneled through VPN ($PROWLARR_IP)"
        elif [ -n "$PROWLARR_IP" ]; then
            fail "Prowlarr IP ($PROWLARR_IP) doesn't match VPN IP ($VPN_IP)!"
        fi
    fi

    # Verify your real IP is different
    REAL_IP=$(wget -qO- --timeout=10 ipinfo.io/ip 2>/dev/null)
    if [ -n "$REAL_IP" ] && [ "$REAL_IP" != "$VPN_IP" ]; then
        pass "Real IP ($REAL_IP) differs from VPN IP — VPN is working!"
    elif [ "$REAL_IP" = "$VPN_IP" ]; then
        warn "Real IP matches VPN IP — are you already running a system-wide VPN?"
    fi
else
    if [ "$GLUETUN_HEALTH" = "unhealthy" ]; then
        fail "Gluetun is unhealthy — VPN not connected"
        fix "Check credentials in .env (these are NOT your VPN login email/password)"
        fix "Check logs: docker logs gluetun 2>&1 | tail -30"
        fix "Try resetting: docker compose down && rm -rf gluetun && docker compose up -d"
    elif [ "$GLUETUN_HEALTH" = "starting" ]; then
        warn "Gluetun health check still starting — wait 30-60 seconds and rerun"
    else
        warn "Gluetun not running — can't test VPN"
        fix "Run: docker compose up -d"
    fi
fi
# ============================================================
# TEST 6: Service web UI accessibility
# ============================================================
header "Web UI Access"

check_http() {
    local name=$1 port=$2
    local code=$(curl -sL -o /dev/null -w "%{http_code}" --max-time 5 "http://localhost:$port" 2>/dev/null)
    if [ "$code" = "200" ] || [ "$code" = "302" ] || [ "$code" = "301" ] || [ "$code" = "307" ]; then
        pass "$name — http://localhost:$port (HTTP $code)"
    elif [ "$code" = "000" ]; then
        # Container might be behind gluetun, check if it's running
        local status=$(docker inspect --format '{{.State.Status}}' "$name" 2>/dev/null)
        if [ "$status" = "running" ]; then
            warn "$name — container running but port $port not reachable from host"
            fix "Port may be mapped on Gluetun. Try: http://localhost:$port"
        else
            fail "$name — not reachable (container not running)"
        fi
    else
        warn "$name — http://localhost:$port returned HTTP $code"
    fi
}

check_http qbittorrent 8080
check_http prowlarr 9696
check_http radarr 7878
check_http sonarr 8989
check_http lidarr 8686
check_http bazarr 6767
check_http jellyfin 8096
check_http seerr 5055
# ============================================================
# TEST 7: Hard link capability
# ============================================================
header "Hard Links"

if [ -d /data/torrents ] && [ -d /data/media ]; then
    # Check if same filesystem
    FS_TORRENTS=$(df /data/torrents --output=source 2>/dev/null | tail -1)
    FS_MEDIA=$(df /data/media --output=source 2>/dev/null | tail -1)

    if [ "$FS_TORRENTS" = "$FS_MEDIA" ]; then
        pass "torrents/ and media/ are on the same filesystem ($FS_TORRENTS)"
        pass "Hard links will work correctly"
    else
        fail "torrents/ ($FS_TORRENTS) and media/ ($FS_MEDIA) are on DIFFERENT filesystems!"
        fix "Hard links only work on the same filesystem/drive"
        fix "Move both directories to the same drive"
    fi

    # Quick hard link test
    TEST_FILE="/data/torrents/.hardlink_test_$$"
    TEST_LINK="/data/media/.hardlink_test_$$"
    if touch "$TEST_FILE" 2>/dev/null && ln "$TEST_FILE" "$TEST_LINK" 2>/dev/null; then
        pass "Hard link test succeeded"
        rm -f "$TEST_FILE" "$TEST_LINK" 2>/dev/null
    elif [ -f "$TEST_FILE" ]; then
        fail "Hard link test failed — filesystem may not support hard links"
        fix "Check filesystem type: df -T /data"
        fix "Hard links work on ext4, btrfs, xfs. NOT on exFAT or ntfs-3g"
        rm -f "$TEST_FILE" 2>/dev/null
    else
        warn "Could not write to /data/torrents (permission issue?)"
        fix "Run: sudo chown -R $(id -u):$(id -g) /data"
    fi
else
    warn "Folder structure not found — skipping hard link test"
    fix "Run: sudo bash setup-folders.sh"
fi
# ============================================================
# SUMMARY
# ============================================================
echo ""
echo "========================================="
echo -e "  ${GREEN}Passed: $TOTAL_PASS${NC}   ${RED}Failed: $TOTAL_FAIL${NC}   ${YELLOW}Warnings: $TOTAL_WARN${NC}"
echo "========================================="

if [ $TOTAL_FAIL -eq 0 ] && [ $TOTAL_WARN -eq 0 ]; then
    echo -e "\n  ${GREEN}${BOLD}All checks passed! Your stack is ready to go.${NC}\n"
elif [ $TOTAL_FAIL -eq 0 ]; then
    echo -e "\n  ${YELLOW}${BOLD}No failures, but check the warnings above.${NC}\n"
else
    echo -e "\n  ${RED}${BOLD}Some checks failed. Follow the fix instructions above.${NC}"
    echo -e "  ${BOLD}If stuck, check: docker logs <container-name>${NC}\n"
fi

Troubleshooting Seerr

I am installing seerr and am getting the following error. Here is my compose setup:

It looks like you forgot to paste the specific text of the error! However, based on the official Seerr image specifications and a common pitfall in your current compose.yaml configuration, the issue is almost certainly a permissions error (Permission Denied / Exit Code 13) or a healthcheck failure.

The official Seerr image runs natively as a non-root user (node with UID/GID 1000), which completely alters how it handles local directory volumes.

Here are the primary fixes to get your setup running perfectly:

Because Seerr runs as UID 1000, it cannot read or write to your configuration folder if it was originally created by root (which Docker often does when initializing empty volumes/directories).

To fix this on your host machine, run the following command to hand ownership over to UID 1000:

Bash
``` sudo chown -R 1000:1000 /path/to/your/seerr_config

<div class="code-block ng-tns-c602925248-25 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk--1"><div class="formatted-code-block-internal-container ng-tns-c602925248-25"><div class="animated-opacity ng-tns-c602925248-25"></div></div></div>*(Replace `/path/to/your/seerr_config` with the actual path on your host machine where that named volume or directory resides).*

## 2. Adjust the Healthcheck Route

In your healthcheck block, you are checking the endpoint `/api/v1/settings/public`. While this endpoint exists, Seerr provides a dedicated health API endpoint designed specifically for containers to probe.

Change your healthcheck test to target `/api/v1/status`:

<div class="code-block ng-tns-c602925248-26 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-yaml"><div class="formatted-code-block-internal-container ng-tns-c602925248-26"><div class="animated-opacity ng-tns-c602925248-26"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c602925248-26 ng-star-inserted"><span class="ng-tns-c602925248-26">YAML</span><div class="buttons ng-tns-c602925248-26 ng-star-inserted"></div></div></div></div></div>```
    healthcheck:
      test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
      start_period: 20s
      timeout: 3s
      interval: 15s
      retries: 3