Objective

Make qBittorrent’s inbound port (6881 TCP+UDP) reliably reachable from WAN without manual SOAP-poking the Rogers Ignite gateway each time the router reboots or its UPnP table gets pruned.

Context

On 2026-05-16 I discovered port 6881 was never actually reachable from outside the LAN — qBittorrent’s connection_status: connected is a self-report from tracker pings, not a real external probe. canyouseeme.org confirmed the port was closed. This single fact explained months of “trackers report N seeds, qBittorrent connects to 2” symptoms: without inbound, qBittorrent can only initiate outbound, so every peer behind their own NAT was unreachable.

The router supports UPnP. SSDP M-SEARCH from the host found the Rogers Ignite gateway’s IGD at http://10.0.0.1:49152/IGDdevicedesc_brlan0.xml (Linux/5.4.201-prod-23.2-231009, UPnP/1.0). The gateway happily responded.

The container can’t reach it. qBittorrent has upnp: True set, but its UPnP discovery requests never arrive at the router. Root cause: Docker bridge networks silently drop multicast traffic on 239.255.255.250 (SSDP). The container has no path to discover the IGD, so it has no way to add a port mapping. This is structural, not configuration — every containerized qBittorrent on Docker bridge networking has the same problem and no one in the linuxserver/qbittorrent docs flags it.

The workaround applied today: I parsed the IGD description from the host, found the WANIPConnection control URL, and POSTed AddPortMapping SOAP envelopes for TCP and UDP. Both returned HTTP 200, the router enumerated them as entries [1] and [2], and the canyouseeme.org re-probe confirmed Success. Effect was immediate — AoT S04E17 jumped from 2 connections to 17, and a previously-stalled Industry release downloaded at 60 KB/s on first try.

Why this is fragile:

  • UPnP mappings on consumer routers are stored in volatile state. A router reboot, firmware push (Rogers does these roughly quarterly), or lease-table cleanup wipes them. The mapping I added today has lease duration 0 = “until removed,” but that doesn’t survive a power cycle.
  • The fix lives nowhere in the repo. The next time the router forgets, future-me will spend another debugging session re-deriving it.
  • No alerting — when the mapping disappears, qBittorrent will silently revert to the “trackers report seeds, we can’t connect” state and look like a swarm-quality problem.

Approach

Three viable patterns, pick one:

Option A — network_mode: host for qBittorrent

Change docker-compose.yml: qBittorrent moves off the bridge network onto the host network. Container’s eth0 becomes the host’s en0 directly. qBittorrent’s built-in UPnP discovery starts working because SSDP multicast is no longer trapped behind the bridge.

Pro: zero ongoing maintenance. qBittorrent auto-renews UPnP on its normal schedule. Con: bypasses the nginx gateway for qBittorrent’s WebUI. Need to either expose qBittorrent’s WebUI port directly (8080 conflicts with nginx) or move qBittorrent to a different WebUI port and route nginx to localhost. Also blows away the per-service network isolation that the rest of the stack assumes.

Option B — UPnP renewal cron on the host

Reuse today’s working SOAP-from-host approach. Add a small Python or shell script that re-runs AddPortMapping against the gateway. Schedule via macOS launchd or a Docker sidecar that has host network access. Run hourly.

Pro: minimal blast radius. Doesn’t touch qBittorrent’s container, doesn’t touch nginx routing. Self-heals after a router reboot within an hour. Con: depends on Rogers/Hitron continuing to expose UPnP at port 49152 (vendor-firmware-specific URL paths). Also adds a host-level cron task that’s outside the docker-compose lifecycle, which is a deviation from the stack’s “one-command bring-up” ethos.

Option C — Document manual setup in router admin, abandon UPnP

Walk through http://10.0.0.1 → Advanced → Port Forwarding, add a static rule for TCP+UDP 6881 → 10.0.0.46. Reserve 10.0.0.46 as a DHCP static lease so the target IP can’t drift. Document the steps in docs/ with screenshots.

Pro: survives router reboots permanently. Zero code. Con: breaks on router replacement, ISP swap, or new LAN topology. Requires every future operator (including future-me on a fresh setup) to remember the manual step. Doesn’t compose with the “declarative” goal stated in AGENTS.md.

Recommended: Option B. Cleanest fit for the existing architecture. Option A is tempting but the nginx-gateway disruption is real, and Option C is what we’ve effectively had for months by accident.

Next Actions

  • Create new worktree from main: wt switch-create persist-upnp-portmap ~/docker/media-stack
  • Pick Option A vs B vs C (see notes above; recommend B)
  • If B: write scripts/upnp-renew.sh that:
    • Discovers the IGD via SSDP (socket to 239.255.255.250:1900) with a fallback to a known URL pattern for Rogers/Hitron
    • Parses the WANIPConnection control URL
    • Issues AddPortMapping for TCP+UDP 6881 → <configurable LAN IP>:6881
    • Logs to stderr; exit 0 on success, nonzero on transport failure
  • If B: add launchd plist (or compose-level cron container) to run the renewal hourly. Lives in nix/home-manager/files/launchd/ or equivalent for the stack’s deploy mechanism.
  • If B: parameterize port + LAN IP + router URL via env (.env additions: QBITTORRENT_PORT_FORWARD_PORT=6881, QBITTORRENT_HOST_LAN_IP=10.0.0.46, ROUTER_UPNP_URL= (optional override))
  • Add a simple external reachability healthcheck (e.g., calls a public probe service or a personal endpoint on a VPS) and alerts via the existing observability stack (SigNoz) when it fails. Catches silent loss-of-port-forward.
  • Update docs/ARCHITECTURE.md and AGENTS.md to document the UPnP-in-Docker structural issue and the chosen workaround.
  • Open PR against main.

Resources

  • Router: Rogers Ignite gateway (Hitron/Sagemcom CGM4140COM-equivalent firmware), Server: Xfinity Broadband Router Server
  • LAN: gateway 10.0.0.1, Mac 10.0.0.46 on en0
  • IGD description URL (vendor-specific path): http://10.0.0.1:49152/IGDdevicedesc_brlan0.xml
  • WAN IP service URL: http://10.0.0.1:49152/upnp/control/WANIPConnection0
  • Service type: urn:schemas-upnp-org:service:WANIPConnection:1
  • SOAP action used: AddPortMapping (with NewLeaseDuration=0 for persistent-until-removed)
  • External probe used to verify: https://canyouseeme.org/ (form POST with port= and IP=)
  • UPnP IGD spec: UPnP-gw-WANIPConnection v1
  • Alternative discovery tool: miniupnpc (CLI upnpc -a <internal_ip> <port> <port> TCP)

Notes

The SSDP M-SEARCH probe and the SOAP envelopes from today’s session are reproduced in 2026-05-16 Media Stack Download Pipeline Recovery — that script is essentially ready to drop into scripts/upnp-renew.sh with minor cleanup (parameterization, error handling, structured logging).

Independent of this work, consider whether the wider answer is just “stop relying on public-tracker BitTorrent for old content” — Usenet (Newshosting + NZBGeek, ~$15/mo) sidesteps the port-forward question entirely for any release Usenet has. Port-forwarding still helps with anime grabs from Nyaa.si (private indexer doesn’t exist for that), so even with Usenet this project is worth landing.

Related to Persist Prowlarr Indexer Priorities in setup-prowlarr.sh in that both projects encode runtime API state into the repo’s source-of-truth scripts. Same general pattern: “fix applied live via API → drift between repo and reality → encode back.”

Decisions

2026-05-16 — Apply SOAP-from-host now, decide on persistence pattern in this project

Status: decided

Context: Port 6881 was unreachable from WAN. Needed inbound for any real torrent throughput. Couldn’t afford to scope the durable fix during the debugging session.

Decision: Add the UPnP mapping via SOAP from the host today. Treat persistence as a separate project (this one).

Alternatives considered: (a) Switch qBittorrent to network_mode: host immediately — too invasive without testing the WebUI gateway impact. (b) Walk through the router admin UI manually — also works but doesn’t compose with the stack’s declarative goal.

Rationale: The runtime fix unblocks downloads in seconds; the persistence question deserves its own deliberation about which of three viable options to land in source.

Persist Prowlarr Indexer Priorities in setup-prowlarr.sh — sibling project from the same session, same “API now, encode in repo later” pattern DIY NAS