Skip to content

qBittorrent Tracker Safety Guide

qBittorrent Prowlarr BEP 27

Guide Info

Read time: ~45 min
Hands-on: 30 min setup
Difficulty: Intermediate

A complete, safe approach to appending ngosang/trackerslist to public torrents in qBittorrent — without ever leaking a public tracker into a private one. Covers the classification logic, the full implementation, and the daily-operations workflow.

Why this matters

qBittorrent's native "Automatically add these trackers to new downloads" setting applies to every torrent — no exceptions for private trackers. Enabling it in a mixed stack (Sonarr/Radarr pulling from both private and public indexers) is an instant, permanent ban on most private trackers. This guide describes a selective classifier that avoids that risk.

AI-Assisted Setup — Copy this prompt to your AI

Want an AI (Claude, ChatGPT, Gemini) to help adapt this guide to your stack? Copy-paste below and fill in the placeholders.

I'm implementing the qBittorrent Tracker Safety pattern described in
https://docs.example.homelab/guides/qbit-tracker-safety/

My setup:
- qBittorrent version: <YOUR_VERSION>  (must be 5.0+)
- qBittorrent container image: <e.g. lscr.io/linuxserver/qbittorrent:latest>
- Prowlarr is running and reachable at: <http://prowlarr:9696>
- Private trackers currently in Prowlarr: <LIST_THEM>
- Docker compose file path: <e.g. /srv/storage/docker/apps/media.yml>
- Env file with credentials: <e.g. /srv/storage/docker/.env>
- qBittorrent WebUI credentials: <USERNAME / PASSWORD or env vars>

Help me:
1. Add the required compose volume mounts for AutoRun hook access
2. Drop in the classifier script (Python 3, stdlib only)
3. Enable BOTH qBit AutoRun hooks (on-added + on-finished) via the WebUI API
4. Add a cron entry for the daily safety-net sweep

Run --dry-run first to verify classification. Don't flip anything live
until I confirm the private/public split looks correct in the output.

Who This Is For

  • You run a mixed *arr stack with both private and public tracker indexers in Prowlarr
  • You want public torrents to benefit from auto-injected trackers for peer discovery
  • You do not want to manually maintain per-torrent tags or category splits
  • You're running qBittorrent 5.0+ (the BEP 27 field was added in 5.0.0)

What You'll Build

A classifier that runs in two places:

  1. qBittorrent AutoRun hook — fires instantly when a torrent is added or finishes downloading
  2. Daily cron — full-library sweep as a safety net + trackerslist refresh

Each run queries Prowlarr for the current list of private indexers, combines it with manual overrides, then classifies every torrent via BEP 27 (primary) + hostname matching (secondary). Public torrents get trackerslist URLs injected. Private torrents are left alone — and if any trackerslist URLs ever leaked onto a now-private torrent, they're stripped on the next run.

System architecture — Prowlarr, manual overrides, and ngosang/trackerslist feed the classifier script, which uses two gates (BEP 27 flag + hostname blocklist) to decide what to do via the qBittorrent WebUI API. Triggered by qBit AutoRun hooks and a daily cron. System architecture — sources on top, classifier in the middle, qBit API at the bottom, triggers on the left.

Reading Order

  1. The Problem — why naive trackerslist injection is dangerous with private trackers
  2. How Classification Works — BEP 27 primer, two-gate architecture, and reclassification cleanup
  3. Setup — compose mounts, script, AutoRun hook (both events), cron fallback
  4. Daily Operation — adding private trackers to Prowlarr, verifying classification, troubleshooting

The Problem

Why You'd Want Extra Trackers

Public torrents on obscure or aging content often struggle with peer discovery. The handful of trackers embedded in a typical .torrent file might be dead, rate-limited, or geographically slow for you. Adding more trackers increases the chance of finding live peers, directly improving download speed and swarm health.

ngosang/trackerslist is the de-facto public tracker list. It's community-maintained, auto-updated, and pruned for live endpoints. A single paste into qBittorrent's "Automatically add these trackers to new downloads" field expands every new torrent's tracker set by 20+ endpoints instantly.

For a stack serving only public content, that's the end of the discussion — turn it on, download faster, move on.

Why You Can't Just Turn It On

The global setting has no awareness of which torrents are private

qBittorrent's global "auto-add trackers" setting applies to every torrent the client downloads — including ones from private trackers. Private trackers ban clients that announce their torrents to unauthorized trackers. The ban is usually instant, permanent, and cabal-shared (propagates to every tracker run by the same admin network).

Private trackers detect this in three different ways:

Detection How
Peer scanning The tracker connects to its own swarm from an unauthorized IP using peer info leaked via public trackers
DHT monitoring Tracker ops run DHT nodes watching for their info-hashes appearing in public DHT
Scrape correlation Public trackers that scrape the private tracker's info-hash get cross-referenced

Any one of these is sufficient. Once a private tracker flags you, recovery is nearly impossible — accounts, invite trees, and linked accounts on other cabal-aligned trackers are all at risk.

Why You Can't Segregate By Category Either

A natural first instinct is: "I'll put private torrents in one category and public in another, then only inject trackers into the public category." This doesn't work in an *arr stack.

  • Sonarr/Radarr hand torrents to qBittorrent using one category per download client (e.g. tv-sonarr, radarr). This category is determined by the *arr app, not the indexer — so a show Sonarr grabbed from a private tracker AND the same show grabbed from a public tracker both land in tv-sonarr.
  • Prowlarr's per-indexer category overrides help with new torrents but don't retag existing ones.
  • qBittorrent only allows one category per torrent, so you can't mix category-based routing with private/public labels without losing the *arr ownership tag.

What We Actually Need

A classifier that decides per-torrent whether injecting extra trackers is safe — using the torrent itself as ground truth, not metadata from Prowlarr or user-assigned categories. The next section covers the mechanism that makes this possible: the BEP 27 private flag.

Real scenarios this needs to handle

  • You download from a private tracker via Sonarr; it lands in tv-sonarr alongside public torrents
  • You manually paste a magnet link from a friend — Prowlarr never saw it
  • You add a new indexer to Prowlarr as private after you've already been downloading from it
  • Bitmagnet serves public DHT content but Prowlarr flags it as private (because it's a local torznab endpoint)
TL;DR

Globally enabling qBittorrent's trackerslist injection will permanently ban you from any private tracker it touches. Category-based segregation doesn't work because *arr apps mix private and public content in the same category. What's needed is per-torrent classification at the client level, using the torrent's own metadata as ground truth.


How Classification Works

BEP 27 — The Private Flag

BEP 27 (Private Torrents) is the BitTorrent standard that defines how private tracker content is marked. Private tracker software (Gazelle, Ocelot, UNIT3D — the frameworks powering virtually every reputable private tracker) sets a single field in each .torrent file's info dict:

info:
  private: 1

The spec says clients MUST ONLY announce to the private tracker and MUST ONLY connect to peers returned by it — no DHT, no PEX, no LSD. This flag is how private trackers protect their swarms; every modern BitTorrent client honors it.

Two properties make it extremely reliable for our purposes:

  • Part of the info-hash. Removing or modifying the flag changes the torrent's identity — the resulting torrent can no longer cross-seed with the original. There's no way to "strip" it without breaking the torrent.
  • Set by default in major tracker software. Gazelle source code adds it automatically on upload ("add private tracker flag and sort info dictionary"). UNIT3D and Ocelot do the same. Even semi-private trackers (open registration) set it, per the installgentoo wiki definition.

qBittorrent 5.0+ exposes the flag directly via its WebUI API as torrent.private. That's our ground truth.

Why not rely on Prowlarr metadata alone?

Prowlarr's privacy field is indexer metadata — it describes the tracker, not the torrent. It's also unreliable for our purposes:

  • Bitmagnet is flagged private in Prowlarr because it's a local torznab endpoint, but every torrent it serves is public DHT content
  • Manual magnet imports bypass Prowlarr entirely
  • Prowlarr definitions are community-maintained and can lag reality
  • A private tracker added to Prowlarr after you've been using it leaves existing torrents misclassified

BEP 27 travels with the torrent file itself, so none of these edge cases matter.

The Two-Gate Safety Stack

BEP 27 alone covers the overwhelming majority of cases, but a belt-and-suspenders design closes the remaining gaps. Every torrent is classified using two gates in sequence, with a final cleanup pass for previously-misclassified torrents:

Tracker classification decision flow — BEP 27 primary gate, Prowlarr-synced hostname blocklist secondary gate, and removeTrackers cleanup for leaked public trackers on now-private torrents Two-gate classification with cleanup. BEP 27 catches nearly everything; the hostname blocklist is pure safety net.

Gate 1 — BEP 27 flag (primary)

If torrent.private == True, it's private. No injection. Reason logged as bep27.

Gate 2 — Hostname blocklist (safety net)

The script queries Prowlarr's API at runtime for every indexer with privacy == "private" and extracts hostnames from their URLs. If any of the torrent's announce URLs match a hostname in this list, it's private regardless of BEP 27. Reason logged as hostname.

This gate exists for two edge cases:

  • Amateur or homebrew trackers that forgot to set the BEP 27 flag
  • Alternate announce hostnames that differ from the indexer URL (TorrentLeech's site is torrentleech.org but its announce is tleechreload.org)

Manual Overrides

A small overrides file handles two final edge cases that auto-sync can't cover:

Syntax Meaning Example
hostname.tld Add to blocklist (treat as private) tleechreload.org — TL's alt announce host
-hostname.tld Remove from blocklist (Prowlarr false-positive) -bitmagnet — serves public DHT despite private flag

The final blocklist is: (Prowlarr private indexers ∪ manual additions) − manual exclusions.

Reclassification Cleanup

The trickiest edge case: what if a torrent was classified public earlier (and got trackerslist injected), but has since been reclassified private? The tags flip, but the previously-injected trackers are still attached — a leak.

The script handles this by checking every torrent classified private for attached trackers that match the current trackerslist, and calling qBittorrent's removeTrackers API to strip them. This makes classification bidirectional: public→private transitions undo prior injection.

Fail-Safe Behavior

If the script can't reach Prowlarr, it refuses to run rather than proceeding with a stale blocklist. A missed injection sweep is harmless — misclassifying a private torrent as public is not. This matters most when you've just added a new private indexer to Prowlarr: if Prowlarr is briefly unreachable at 4 AM, the script skips that run cleanly.

Coverage matrix

Scenario Gate that catches it
Mainstream private tracker (TorrentLeech, seedpool, etc.) BEP 27 ✅
Manual magnet from an unknown private tracker BEP 27 ✅
Amateur tracker missing BEP 27 but in Prowlarr Hostname ✅
Private tracker using alternate announce host Manual override ✅
Previously-public torrent reclassified private Reclassification cleanup ✅
Bitmagnet (Prowlarr flags private, content is public) Manual exclusion (-bitmagnet) ✅
TL;DR

BEP 27's private flag, exposed by qBittorrent 5.0+ as torrent.private, is the reliable ground-truth signal for "is this a private torrent". A Prowlarr-synced hostname blocklist catches the rare cases where BEP 27 is missing. A small overrides file handles alt-hosts and false-positives. Reclassification cleanup strips any leaked public trackers from torrents now flagged private. The script refuses to run if Prowlarr is unreachable.


Setup

Overview

Four pieces need to be in place:

  1. The classifier script itself, on the host
  2. A wrapper shell script that runs inside the qBittorrent container (the AutoRun hook)
  3. Compose mounts so the container can see both scripts + credentials
  4. qBittorrent AutoRun preferences set to call the hook
  5. A cron fallback on the host

Prerequisites

Requirement Why
qBittorrent 5.0+ BEP 27 private field was added in the 5.0.0 WebUI API
Prowlarr API key accessible via env Script auto-syncs the private hostname blocklist from Prowlarr at runtime
qBittorrent WebUI credentials accessible via env Script needs to authenticate against the WebUI API
python3 inside the qBittorrent container The LinuxServer.io qBittorrent image ships with it by default

1. Compose Volume Mounts

The AutoRun hook runs inside the qBittorrent container, so the scripts and credentials must be mounted in. In your qBittorrent service definition:

qbittorrent:
  volumes:
    - $DOCKERDIR/appdata/qbittorrent:/config
    - /data/torrents:/data/torrents
    - $DOCKERDIR/scripts/system:/custom-scripts:ro    # AutoRun hook scripts
    - $DOCKERDIR/.env:/run/custom-scripts.env:ro      # creds for the hook

Don't nest the .env mount

Mount .env to /run/custom-scripts.env, not to /custom-scripts/.env. Bind-mounting a file inside another read-only bind mount is fragile and often fails on container recreate.

Recreate the container after editing:

docker compose -f docker-compose.yml -f apps/misc.yml up -d qbittorrent

2. The Classifier Script

Place on the host at $DOCKERDIR/scripts/system/trackerslist-inject.py. Key behaviors:

  • Loads environment from /srv/storage/docker/.env (host) or /run/custom-scripts.env (container)
  • Auto-resolves the qBittorrent + Prowlarr URLs via docker inspect when run from the host, or uses Docker DNS names (qbittorrent:8080, prowlarr:9696) when run inside the container
  • Accepts --dry-run, --tag-only, --hash <hash>, --list-hosts flags

Why two URL resolution modes

qBittorrent's WebUI\HostHeaderValidation rejects requests where the Host header doesn't match what it expects. From the host via a published port (localhost:9898), the host header is wrong. Resolving the container IP and hitting that directly sidesteps the issue.

Make it executable:

chmod +x /srv/storage/docker/scripts/system/trackerslist-inject.py

3. The AutoRun Hook Wrapper

qBittorrent's AutoRun feature expects a single command string. Since we need to set environment variables and log output, use a small wrapper shell script at $DOCKERDIR/scripts/system/trackerslist-inject-hook.sh:

#!/bin/bash
# qBit AutoRun hook — runs inside the qbittorrent container.
set -u
LOG=/config/trackerslist-hook.log
HASH="${1:-}"

{
  echo "[$(date -Iseconds)] hook fired hash=${HASH:0:12}"
  export QBIT_URL=http://localhost:8080
  /usr/bin/python3 /custom-scripts/trackerslist-inject.py --hash "$HASH"
  echo "[$(date -Iseconds)] hook done rc=$?"
} >> "$LOG" 2>&1

The script logs to /config/trackerslist-hook.log inside the container — visible from the host at $DOCKERDIR/appdata/qbittorrent/trackerslist-hook.log.

4. Wire qBittorrent AutoRun

qBittorrent has two separate AutoRun hooks. Both need to be enabled:

Preference key Fires when
autorun_on_torrent_added_enabled + autorun_on_torrent_added_program Torrent is added to the client
autorun_enabled + autorun_program Torrent finishes downloading

Easy mistake

The option in qBit's UI labeled "Run external program on torrent added" is the first key. The one labeled "Run external program on torrent finished" is the second. They're independent — enabling one does not enable the other. If you only enable one, newly-added magnet torrents may not get classified until they finish downloading.

Set both via the WebUI API:

# Authenticate once (adjust user/pass/IP)
curl -s -c /tmp/qbit.cookies -X POST "http://10.0.0.7:8080/api/v2/auth/login" \
  -H "Referer: http://10.0.0.7:8080" \
  --data-urlencode "username=Cognition0047" \
  --data-urlencode "password=YOUR_PASSWORD" > /dev/null

# Set both hooks
curl -s -b /tmp/qbit.cookies -X POST \
  "http://10.0.0.7:8080/api/v2/app/setPreferences" \
  --data-urlencode 'json={
    "autorun_on_torrent_added_enabled":true,
    "autorun_on_torrent_added_program":"/custom-scripts/trackerslist-inject-hook.sh \"%I\"",
    "autorun_enabled":true,
    "autorun_program":"/custom-scripts/trackerslist-inject-hook.sh \"%I\""
  }'

Or manually via the WebUI: Options → Downloads → Run external program on torrent added/finished, set both to:

/custom-scripts/trackerslist-inject-hook.sh "%I"

5. Cron Fallback

Add a daily (or hourly) full sweep on the host. This catches anything the hook missed and refreshes the trackerslist from GitHub:

# Add to crontab -e
0 4 * * * /srv/storage/docker/scripts/system/trackerslist-inject.py >> /tmp/trackerslist-inject.log 2>&1

For tighter reclassification windows (when you add private indexers to Prowlarr), run hourly instead:

0 * * * * /srv/storage/docker/scripts/system/trackerslist-inject.py >> /tmp/trackerslist-inject.log 2>&1

6. Initial Sweep

Before going live, do a dry-run to verify classification:

/srv/storage/docker/scripts/system/trackerslist-inject.py --dry-run

Expected output:

[info] blocklist: 13 private hosts (Prowlarr-synced + overrides)
[info] qbit: http://10.0.0.7:8080   prowlarr: http://10.0.0.162:9696
[info] 98 torrents to process
[info] trackerslist: 20 entries from https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt
[result] private: 70 (bep27=70 hostname=0)   public: 28

Check the counts match your expectations. If bep27=0 and all classifications came from hostname, something is wrong with the BEP 27 detection. If the private count is far lower than expected, check that your private tracker announce URLs actually match Prowlarr's indexer URLs (see the operation guide for debugging).

Once satisfied, flip it live:

/srv/storage/docker/scripts/system/trackerslist-inject.py --tag-only   # tag without injecting
# ...spot-check tags in qBit WebUI...
/srv/storage/docker/scripts/system/trackerslist-inject.py               # tag + inject
TL;DR

Mount the scripts dir + .env into qBittorrent, place the classifier + hook wrapper, enable both AutoRun preferences (on-added AND on-finished), add a cron fallback. Run --dry-run first to verify classification, then --tag-only to flip tags without injection, then the full run once you're satisfied.


Daily Operation

Adding a New Private Tracker

This is the main day-to-day workflow and it's zero-config on the script side:

  1. Add the indexer in Prowlarr as normal (privacy = private)
  2. Next time the script runs (AutoRun hook on new torrents, or 4 AM cron), the blocklist auto-syncs from Prowlarr's API

No file edits, no restarts. Existing torrents from that tracker get correctly reclassified (and any leaked public trackers get stripped) on the next full sweep.

Want immediate reclassification

Run the sweep manually: /srv/storage/docker/scripts/system/trackerslist-inject.py. Completes in ~10 seconds for ~100 torrents.

Handling Manual Overrides

Edit $DOCKERDIR/scripts/system/trackerslist-private-hosts.txt:

# Add this host to the blocklist (plain line)
tleechreload.org

# Remove from blocklist (leading dash)
-bitmagnet

When to add vs. remove:

  • Add when a private tracker's announce host differs from its indexer URL (most common for trackers using CDN hostnames or passkey-prefixed domains)
  • Remove when Prowlarr's metadata is wrong for your use case (Bitmagnet is the canonical example — it's a local torznab that serves public DHT)

Verifying Classification

In the qBittorrent WebUI: click the Tags section in the sidebar → filter by private or public. Every torrent should be tagged one way or the other. Untagged torrents indicate the hook didn't fire or the sweep hasn't run yet.

Full dry-run with reasoning:

/srv/storage/docker/scripts/system/trackerslist-inject.py --dry-run

Each sample line is prefixed with [bep27] or [hostname] showing which gate classified it.

Every unique tracker host in your library:

/srv/storage/docker/scripts/system/trackerslist-inject.py --dry-run --list-hosts

Hosts matched by the private blocklist are marked <PRIVATE>. This is the best tool for curating manual overrides — if you see a private tracker's announce host with 20 torrents attached and no <PRIVATE> marker, add it to the overrides file.

Watching the hook live:

docker exec qbittorrent tail -f /config/trackerslist-hook.log

Or on the host:

tail -f /srv/storage/docker/appdata/qbittorrent/qBittorrent/../trackerslist-hook.log

Troubleshooting

A newly added torrent has no tags.

The hook didn't fire. Check:

  1. docker exec qbittorrent tail /config/trackerslist-hook.log — is there a recent entry?
  2. Both AutoRun preferences are enabled (on-added AND on-finished). The UI labels are independent toggles.
  3. Container volume mounts are present: docker exec qbittorrent ls -la /custom-scripts/ /run/custom-scripts.env
  4. Manually trigger for that hash: docker exec qbittorrent /custom-scripts/trackerslist-inject-hook.sh "<full-hash>" — any errors go to the log

A private torrent is tagged public.

Rare with BEP 27 as the primary gate, but possible for amateur trackers that don't set the flag:

  1. Run --dry-run --list-hosts and find the torrent's announce host
  2. If the host is a recognizable private tracker, add it to the overrides file
  3. Re-run the script — the torrent will reclassify and any injected public trackers will be stripped automatically

A public torrent is tagged private.

Usually a Prowlarr false-positive. Example: Bitmagnet is flagged private in Prowlarr but serves public DHT content. Add an exclusion line to the overrides file:

-bitmagnet

Script exits with [fatal] blocklist build failed.

Prowlarr is unreachable. Expected behavior — the script refuses to run rather than proceed with a stale blocklist. Fix Prowlarr connectivity; next scheduled run will pick up automatically.

Classification is right but no trackers get injected.

Check that the trackerslist URL is reachable from inside the container:

docker exec qbittorrent curl -sI https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt

If blocked, the container network is restricted (possible with gluetun-routed qBit configurations). The script will print [warn] could not fetch trackerslist and skip injection cleanly.

What to Audit Periodically

Every month or so:

  • Run --list-hosts and spot-check unknown announce hosts — any private tracker announce URLs that aren't getting caught?
  • Verify all your Prowlarr private indexers are flagged privacy=private in Prowlarr's API
  • Check the classification split matches expectations (a major drift suggests a misconfigured indexer)

Healthy steady state (real example)

[info] blocklist: 13 private hosts (Prowlarr-synced + overrides)
[info] 98 torrents to process
[result] private: 70 (bep27=70 hostname=0)   public: 28
[done] injected into 28/28 public torrents
All 70 private torrents classified by BEP 27 — the hostname gate caught zero, meaning it's serving as pure safety net. That's the expected pattern for a mature setup.

TL;DR

Adding a private tracker in Prowlarr is zero-config — the blocklist auto-syncs. Use --dry-run --list-hosts to audit unknown hosts. Hook not firing? Check that BOTH AutoRun preferences are enabled. Misclassification? Edit the overrides file (add plain hostname, or prefix with - to exclude).