LAB-NOTES

Building an Arr Stack: What Each Service Should Own

Animated terminal showing the Arr stack network topology and smaller VPN failure domains.
The stack split into normal, VPN-bound, and separate network domains.

An Arr stack is easy to assemble and surprisingly easy to make opaque. Every container can be healthy, every Web UI can load, and the overall system can still be difficult to reason about because the real workflow lives in the handoffs between services.

I ended up revisiting mine after a VPN and port-forwarding problem forced me to trace the path end to end. That was useful for a different reason: it exposed which services actually needed to share a failure domain, which ones only needed to talk to each other, and where I had let convenience blur the boundaries.

This is the first of three posts. It starts with the boring part that saves the most time later: deciding what each service owns, making the storage layout support hardlinks, and keeping the VPN boundary as small as it needs to be.

The examples are intentionally generic. There are no real addresses, hostnames, credentials, account details, media titles, or deployment-specific paths here.

The stack is a pipeline, not one application

The basic download path is short:

request / monitored item
        |
        v
 Sonarr or Radarr
        |
        v
      Jackett
        |
        v
   qBittorrent
        |
        v
 Sonarr/Radarr import
        |
        v
   media library
        |
        +----> Jellyfin
        |
        `----> Tdarr health check

The supporting services sit around that path rather than replacing it:

Recyclarr   -> configuration policy for Sonarr/Radarr
Cleanuparr  -> queue cleanup and blocked-download handling
Gluetun     -> VPN egress and firewall for selected services
FlareSolverr -> browser-challenge helper for compatible indexers

That distinction matters. The easiest way I found to reason about the stack was to stop thinking in terms of “the Arr containers” and instead ask one question for each service:

What state is this application authoritative for?

Once that answer is clear, most of the architecture follows naturally.

Give each service one job

Sonarr and Radarr own media state

Sonarr is the authority for television and Radarr is the authority for movies.

They decide what is monitored, which quality policy applies, whether a release is acceptable, which download client receives it, and where an imported file belongs in the final library.

qBittorrent should not independently reorganize completed media into the library. It downloads. Sonarr and Radarr import.

That gives a clean boundary:

qBittorrent:
  peer connections
  torrent state
  transfer state
  download paths

Sonarr / Radarr:
  media identity
  release policy
  naming
  import state
  final library placement

Keeping that boundary intact also makes failures easier to understand. If a torrent completes but never appears in the library, the question is no longer “which container moved it?” The download client completed a transfer; the Arr application either imported it or recorded why it could not.

Jackett owns indexer translation

Jackett sits between Sonarr/Radarr and the configured indexers.

Its purpose here is compatibility. Sonarr and Radarr speak to Jackett through a normalized interface, and Jackett deals with the indexer-specific side.

That means an indexer failure is not automatically a Sonarr or Radarr problem. The failure may be in one indexer, Jackett itself, DNS, browser-challenge handling, or the network path Jackett uses.

FlareSolverr belongs next to Jackett for the same reason. It is a narrow helper for sites that require supported browser-challenge handling. It is not a media manager and it does not need a public-facing UI.

qBittorrent owns transfer state

qBittorrent is the transfer engine.

It accepts work from the Arr applications, connects to peers, downloads and seeds data, reports progress, and maintains the category/download paths the rest of the stack expects.

Later in the series I use qBittorrent’s filename exclusion feature as one small security control. That control needs to be treated as a version- and workflow-specific policy gate, not as antivirus and not as proof that a download is safe.

Recyclarr owns configuration policy

Recyclarr is useful because quality definitions, profiles, custom formats, scores, and naming policy are configuration that can drift.

It synchronizes the desired policy into Sonarr and Radarr. It does not download anything and it should not be treated as proof that every existing movie or series is assigned to the profile you intended.

Those are separate checks:

Does the desired profile exist?
        !=
Is this item actually assigned to it?

Cleanuparr owns queue supervision

Cleanuparr watches the workflow for conditions that need cleanup or intervention.

In this design it is useful for things such as failed imports, blocked downloads, stalled or otherwise unusable queue entries, and replacement searches after a bad download is removed.

It is an automation and enforcement layer. It is not a malware scanner.

Tdarr is a read-only post-import checker

Tdarr is commonly used for transcoding, but I am deliberately using only a small part of it here: media health checking after import.

The media library is mounted read-only. Scratch space can be writable, but the checker does not need permission to rewrite the authoritative library.

media library
     |
     | read-only
     v
   Tdarr
     |
     `--> structural media health check

This is not a quarantine system. A successful media health check tells me something about the structure of the media file; it does not prove that the file is malware-free.

Jellyfin consumes the result

Jellyfin is at the far end of the pipeline. It reads the organized library and handles playback, clients, metadata and transcoding where required.

It has no reason to participate in torrent acquisition.

That separation is useful operationally and from a permissions standpoint: the media server should not need credentials or network access that only the acquisition side uses.

Fix the storage model before adding automation

Container networking gets a lot of attention in these stacks, but the path layout causes just as many avoidable problems.

The goal is for the downloader and importers to see one consistent filesystem tree:

/data
├── torrents
│   ├── incomplete
│   ├── tv
│   └── movies
└── media
    ├── tv
    └── movies

qBittorrent writes under /data/torrents. Sonarr and Radarr see those same paths and also see their destinations below /data/media.

That consistency matters because it lets the applications use the filesystem directly instead of translating unrelated container paths with Remote Path Mappings.

It also makes hardlinks possible when the download and media directories are on the same filesystem.

/data/torrents/tv/example.mkv
             |
             | hardlink
             v
/data/media/tv/Series/example.mkv

Both directory entries can refer to the same underlying file data. The torrent can continue seeding from the download path while the library gets its organized filename immediately, without storing a second full copy.

If the source and destination are on different filesystems, that does not work. I would verify this before configuring the applications rather than discover it after a large import starts copying data.

Build it in an order that leaves useful evidence

Bringing up every container at once makes the first failure unnecessarily hard to isolate. A better order is to prove one boundary at a time.

1. Storage first

Decide where incomplete downloads, completed downloads and final media live.

Verify that the container-visible paths are consistent and that the paths intended for hardlinks are on the same filesystem.

2. Gluetun by itself

Before attaching another service, verify the VPN tunnel, firewall behavior, intended IPv4/IPv6 policy and provider-side port forwarding if it is part of the design.

Do not treat “container is running” as proof that the network path is correct.

3. qBittorrent

Attach qBittorrent to the VPN namespace and prove that it fails closed.

Then verify the actual torrent settings: listening interface, listening port, random-port behavior, UPnP policy and category paths.

4. Sonarr and Radarr

Add the importers only after the download path works.

Configure one known-good download and import before adding more automation. If that path is not solid, Recyclarr and cleanup rules only add more variables.

5. Jackett and FlareSolverr

Add indexers gradually. Test them independently.

“Jackett is running” does not mean every configured indexer works.

6. Recyclarr

Introduce policy synchronization after Sonarr and Radarr are stable. Use preview/dry-run behavior where available and inspect what it intends to change.

7. Cleanuparr

Start with observation and narrow rules. Cleanup automation should not be the thing hiding a broken normal download/import path.

8. Tdarr

Add read-only health checking after the library path is proven. Transcoding can be a separate project; it does not need to be mixed into the first validation pass.

The VPN boundary should be smaller than the project boundary

This was the architectural change that made the stack much easier to troubleshoot.

A common Compose layout puts every application behind Gluetun because it is convenient:

network_mode: "service:gluetun"

That is exactly what I want for qBittorrent. If the VPN disappears, qBittorrent should not quietly fall back to the host’s normal route.

It is not what I want for the entire control plane.

Sonarr and Radarr do not inherently need their own Internet traffic to share qBittorrent’s network namespace. Recyclarr and Cleanuparr do not need to disappear just because the VPN is unhealthy. Tdarr has nothing to do with acquisition egress at all.

The split I settled on is:

normal Compose network
├── gluetun
├── sonarr
├── radarr
├── recyclarr
└── cleanuparr

share Gluetun network namespace
├── qbittorrent
├── jackett
└── flaresolverr

separate bridge
└── tdarr

qBittorrent, Jackett and FlareSolverr are intentionally coupled to the VPN.

The control-plane applications are not.

Because Sonarr and Radarr share a Docker network with Gluetun, Docker service discovery and Gluetun’s shared-network model still let them reach services inside the Gluetun namespace through the Gluetun service name and the appropriate application port:

sonarr/radarr
     |
     +----> gluetun:<qbit-port>
     |
     `----> gluetun:<jackett-port>

That gives the failure mode I actually want:

VPN failure
 |
 +--> qBittorrent loses Internet access
 +--> Jackett loses Internet access
 +--> FlareSolverr loses Internet access
 |
 +--> Sonarr remains reachable
 +--> Radarr remains reachable
 +--> Recyclarr remains reachable
 +--> Cleanuparr remains reachable
 `--> Tdarr remains reachable

The VPN can fail without taking the tools needed to diagnose the failure down with it.

Do not publish every UI just because Docker makes it easy

This is convenient:

ports:
  - "8080:8080"

but Docker interprets a short port mapping without a host address as a publication on all host interfaces.

For management interfaces I prefer an explicit trusted binding:

ports:
  - "${MGMT_IP}:8080:8080"

or no host publication at all when another controlled access path already exists.

Helper services that do not need direct user access should remain internal.

The same principle applies to authentication. “It is only on my LAN” is not a useful reason to leave an administrative interface unauthenticated.

Keep secrets out of the Compose file you publish

Public examples should contain placeholders, not sanitized-looking real credentials.

For Gluetun specifically, Docker secret files are supported for sensitive values such as OpenVPN credentials. That is preferable to teaching readers to paste real passwords directly into a Compose file.

At a minimum, the public version of a stack should never contain:

VPN credentials
Arr API keys
Jackett API keys
qBittorrent passwords or cookies
webhook URLs
authentication databases
resolved Compose output containing secrets

The same rule applies to screenshots and diagnostic output. A screenshot can undo all the work that went into sanitizing the article around it.

Where this leaves the stack

At this point each service has a clear owner, storage paths are designed for the import behavior we want, and the VPN contains only the applications that actually need to fail with it.

That is enough architecture for one post.

Part 2 will deal with the less comfortable side of a media automation stack: untrusted download metadata and payloads, filename exclusions, queue enforcement, safe canaries, and read-only post-import validation.

Part 3 will turn the assumptions from the first two posts into health invariants and stateful monitoring.

Further reading

  • Docker Compose networking: https://docs.docker.com/compose/how-tos/networking/
  • Docker Compose service/network settings: https://docs.docker.com/reference/compose-file/services/
  • Gluetun inter-container networking: https://github.com/qdm12/gluetun-wiki/blob/main/setup/inter-containers-networking.md
  • Gluetun firewall behavior: https://github.com/qdm12/gluetun-wiki/blob/main/faq/firewall.md
  • Gluetun Docker secrets: https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/docker-secrets.md
  • Recyclarr features: https://recyclarr.dev/guide/features/
  • Cleanuparr features: https://cleanuparr.github.io/docs/features/
  • Tdarr health checking: https://docs.tdarr.io/docs/library-setup/healthcheck/