My arr stack. Hardened Docker Compose config for Jellyfin + Sonarr/Radarr + qBittorrent with VPN namespace isolation and zero-trust ingress.
I run this on Unraid. It took a few months to get the networking right — most guides just slap a firewall rule on the VPN and call it a day. I wanted actual isolation, not "it probably works." Here's what I landed on.
My family uses Seerr to request movies/shows and Infuse on Apple TV to watch them.
-
Docker + Compose
-
Tailscale account. Open these ports in your Tailscale ACL for the host running this stack:
tcp:80,tcp:443(Traefik, bound to your Tailscale IP) andtcp:8096(Jellyfin direct, for LAN clients like Infuse / Apple TV). Nothing else is published to the host. -
ProtonVPN account with WireGuard keys (P2P-enabled servers in NL/CH).
-
Domain on Cloudflare DNS with a scoped API token (not the Global API Key). Create the token at dash.cloudflare.com → My Profile → API Tokens with these permissions on the target zone:
Zone → Zone → ReadZone → DNS → Edit
This is the minimum required for the ACME DNS-01 challenge. See
.env.examplefor the full variable list.
cp .env.example .env # fill in secrets, domain, Tailscale IP, WG keys, CF token
ln -s ../.env arr/.env # each compose stack reads its own .env
ln -s ../.env infra/.env
docker network create traefik_proxy
cd infra && docker compose up -d # traefik first
cd ../arr && docker compose up -d # media pipeline (9 containers)Each compose file declares env_file: ./.env, resolved relative to its own directory — so arr/docker-compose.yml needs arr/.env. Symlinking keeps one source of truth at the repo root.
This is the part that's actually interesting. The services themselves are standard — the value is in how they're wired together.
VPN namespace isolation — qBittorrent doesn't just "use" the VPN. It runs inside gluetun's network namespace (network_mode: service:gluetun), meaning it shares gluetun's entire network stack. The container has no network interface of its own. An init script (10-config.sh) additionally forces BIND_TO_INTERFACE: tun0 as defense in depth. If the VPN drops, there is no path for traffic to take — it's a kernel boundary, not a firewall rule that could be misconfigured.
No published ports, with one exception — only Jellyfin publishes :8096 to the host so LAN clients (Infuse, Apple TV) can hit it directly. Everything else is reachable only through Traefik over the Docker network — there's no way to hit Sonarr/Radarr/Prowlarr/etc. by going to host:port and bypassing TLS + security headers.
Tailscale-only ingress — Traefik binds to ${TAILSCALE_IP}:443, not 0.0.0.0:443. You must be on the Tailscale mesh to reach any service. No ports face the public internet. HTTPS certs are auto-renewed via Cloudflare DNS challenge.
Three isolated networks — traefik_proxy for HTTPS ingress, arr_internal (marked internal: true) for service-to-service, vpn_network for tunnel traffic. Port forwarding from ProtonVPN is automatic — gluetun gets the forwarded port and pushes it to qBittorrent's API.
Cloudflare is used only as the ACME DNS-01 challenge target for cert renewal — control plane, not in the user-traffic path.
arr/ — Gluetun, qBittorrent, Jellyfin, Sonarr, Radarr, Prowlarr, Seerr, Bazarr, Autoheal. Every container has an endpoint-specific health check. Autoheal restarts anything that fails. depends_on: service_healthy prevents cascading startup issues.
infra/ — Traefik v2.10.7 reverse proxy with auto HTTPS via Cloudflare DNS challenge.



