Site B runs the media pipeline across three boxes: bender (request portal, *arrs, download clients), nibbler (the storage), and professor (Plex). This post is the back half, the plumbing, and especially the networking pattern, which is the most reusable idea on the box. The brains got their own pages: Plex is post 15, the *arr naming and quality machinery is post 16.

The obligatory paragraph, meant sincerely: automation is content-neutral, your sources shouldn’t be. Point this at media you have the rights to: your own rips, freely-licensed content, backfills of things you own. The tooling neither knows nor cares. What you feed it is on you.

The cast

request portal (Overseerr)        <- the only public-facing piece
        |
   Sonarr / Radarr                <- library managers (post 16)
        |
     Prowlarr                     <- indexer hub, synced to the *arrs
        |
+------------------------------+
|  gluetun (VPN client)        |  <- everything in this box exits
|    +-- qBittorrent           |     via the tunnel, or not at all
|    +-- SABnzbd               |
+------------------------------+
        |  hardlink into /data/media (post 15's convention,
        |  on nibbler's exported filesystem)
      Plex (on professor)

The pattern: a VPN network namespace

I’ve done VPN-for-downloads every fragile way: VPN on the whole host (everything tunnels, including things that shouldn’t), app-level proxy settings (which apps bypass for DNS or trackers, or quietly reset after an update), and the built-in “kill switch” checkboxes, which are promises. What you want is physics.

Gluetun is physics. It’s a container that connects to your VPN provider and lends out its network namespace:

# compose/bender/services/gluetun.yml (trimmed to the idea)
services:
  gluetun:
    image: qmcgaw/gluetun
    container_name: gluetun
    cap_add: [NET_ADMIN]
    networks: [proxy]
    environment:
      - VPN_SERVICE_PROVIDER=${VPN_PROVIDER}
      - WIREGUARD_PRIVATE_KEY=${VPN_WG_KEY}    # sops, as ever
      - SERVER_COUNTRIES=${VPN_COUNTRY}
    volumes:
      - ${USERDIR}/appdata/gluetun:/gluetun

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent
    network_mode: service:gluetun    # <- the entire trick
    volumes:
      - ${USERDIR}/appdata/qbittorrent:/config
      - /mnt/nibbler/data:/data       # nibbler's export, post 15

  sabnzbd:
    image: lscr.io/linuxserver/sabnzbd
    network_mode: service:gluetun
    volumes:
      - ${USERDIR}/appdata/sabnzbd:/config
      - /mnt/nibbler/data:/data

network_mode: service:gluetun means qBittorrent has no network identity of its own. No interface, no routes, no DNS except gluetun’s. Every packet it can physically emit goes through the tunnel, because there is nothing else. When the VPN drops, gluetun’s internal firewall closes and the clients go dark instead of leaking onto the ISP connection.

Test it yourself once, it’s worth seeing: kill the tunnel on purpose mid-download and watch the peer list freeze. After years of trusting checkboxes, watching the leak be impossible is the moment this pattern sells itself.

The routing consequence everyone hits

Namespace-sharers don’t exist on the proxy network, so Traefik can’t see them, and labels on their own containers do nothing. The labels go on gluetun, which owns the network identity. One router/service pair per app, distinguished by port:

  gluetun:
    labels:
      - traefik.enable=true
      # qBittorrent
      - traefik.http.routers.qbit.rule=Host(`qbit.${INTERNAL_DOMAIN}`)
      - traefik.http.routers.qbit.middlewares=sso@file
      - traefik.http.routers.qbit.service=qbit
      - traefik.http.services.qbit.loadbalancer.server.port=8080
      # SABnzbd
      - traefik.http.routers.sab.rule=Host(`sab.${INTERNAL_DOMAIN}`)
      - traefik.http.routers.sab.middlewares=sso@file
      - traefik.http.routers.sab.service=sab
      - traefik.http.services.sab.loadbalancer.server.port=8081

This is THE faq of the pattern. The symptom (“Traefik 404s but the app is running fine”) points everywhere except here, because the app’s own compose file looks perfectly normal. Note the explicit .service= on each router: with multiple pairs on one container, Traefik’s auto-wiring guesses wrong.

The 3 a.m. gotcha: namespace death

When gluetun restarts (VPN hiccup, image update, your own redeploy), its network namespace is destroyed and recreated. The clients inside keep running, attached to a namespace that no longer routes anywhere. They don’t crash. They sit there looking healthy, downloading nothing, until something restarts them.

The fix is an auto-healer: give the clients a healthcheck that proves real connectivity, and run a small watcher (deunhealth) that restarts anything marked unhealthy:

  qbittorrent:
    labels:
      - deunhealth.restart.on.unhealthy=true
    healthcheck:
      test: ["CMD", "curl", "-sf", "https://api.ipify.org"]
      interval: 60s
      retries: 3

That specific check has a bonus property: it fails both when the namespace is dead AND if the container somehow ends up with naked internet. Either way, a restart into gluetun’s fresh namespace is the correct response. The VPN blipping overnight becomes a log line you read at breakfast instead of a morning incident.

Exposure: one door

Per post 3’s pattern: Overseerr gets an internal route AND an edge route. It’s the household’s front door, with its own accounts and a UI built for “request a thing.” Everything else (*arrs, clients, Prowlarr) stays internal-only behind the SSO middleware. Nobody outside needs a download client’s UI; the request portal is the public API to the whole machine. People ask, the pipeline does, Plex delivers, and my involvement rounds to zero. Bender handles it. It’s what he does.

Next: the deep dives this post keeps pointing at. Plex first.