Wiring Up a VPN-Gated *Arr Stack
Radarr, Sonarr, Lidarr, Prowlarr, Bazarr, and qBittorrent on Proxmox, one container per service, with gluetun standing in as a container-level gateway for the VLAN kill switch I don't have hardware for yet.
a self-hosted download pipeline is usually three pieces: something that finds things, something that fetches things, and something that organizes what got fetched. the fetching piece — and usually the finding piece too — is exactly the kind of traffic that should never leak your real IP.
a real vpn kill switch covers the right way to enforce that: a dedicated network segment, firewall rules that fail closed. i don’t have the hardware for that yet — no spare router-capable box, no managed switch port to spare. this is the version that gets the same guarantee one layer down: instead of a VLAN with a firewall kill switch, one dedicated container that is the gateway for the containers that need it, with the same fail-closed logic implemented in iptables instead of firewall rules.
same mental model either way: force the traffic through the tunnel, block anything that isn’t. every command below is what I actually ran, values swapped for placeholders.
architecture
one container per service, not one big stack sharing a network namespace:
| role | software | routes through the tunnel? |
|---|---|---|
| VPN client + gateway | gluetun | is the tunnel |
| download client | qBittorrent | yes — points its default route at the gluetun container |
| indexer | Prowlarr | yes — its search traffic shouldn’t leak any more than the download traffic should |
| media management | Radarr, Sonarr, Lidarr | no — stays on the normal LAN gateway, direct |
| subtitle management | Bazarr | no — talks to Radarr/Sonarr over the LAN only, no download client or indexer of its own |
gluetun owns the tunnel and the kill switch, nothing else runs on it. Radarr doesn’t originate indexer or torrent traffic itself — it just talks to Prowlarr and qBittorrent over the LAN — so there’s no reason to route it through the tunnel too.
separate containers instead of one shared-network stack means no shared filesystem is assumed anywhere. that assumption breaks more than once below — worth having it in mind going in.
each container came from Proxmox VE community-scripts — the community-maintained helper-script collection, not a hand-rolled template or a docker-compose stack. one script per service, run on the Proxmox host itself:
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/<script-name>.sh)"
worth it purely for the defaults — sane resource sizing, the right base template, and for gluetun specifically, a TUN-passthrough flag at creation time that skips step 1 below entirely. the community-scripts gluetun install is a compiled Go binary running as a native systemd service, not the more commonly-seen qmcgaw/gluetun Docker image — same environment-variable names either way, but no docker/docker-compose involved here, and the default template ships pointed at OpenVPN rather than WireGuard (see step 3).
step 1 — TUN device passthrough on the gluetun container
gluetun needs /dev/net/tun, which an unprivileged container doesn’t expose by default. check whether the install script already handled this before doing it manually:
grep tun /etc/pve/lxc/<container-id>.conf
if that comes back empty, it’s a manual edit — on the host, not inside the container:
# on the Proxmox host
cat >> /etc/pve/lxc/<container-id>.conf << 'EOF'
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net dev/net none bind,create=dir
EOF
pct reboot <container-id>
verify inside the container:
ls -la /dev/net/tun
# expect: crw-rw-rw- ... 10, 200 ... /dev/net/tun
there's no UI checkbox for thisconf-file edit only
step 2 — get a VPN config, pick a server
generate a WireGuard config from your provider. if port-forwarding/seeding matters to you, check the server explicitly supports P2P before committing — not every server does, and it’s not always obvious from the country alone.
a whole region can be dead for your account specificallycheck before assuming your setup is broken
step 3 — configure gluetun
gluetun’s config lives in a .env file. the community-scripts install defaults to OpenVPN — switch it to WireGuard, and use VPN_SERVICE_PROVIDER=custom rather than a named-provider value if you’re pinning an exact server: gluetun’s built-in per-provider modes manage their own server selection and reject a manually specified endpoint outright, erroring with something like “endpoint already set.”
VPN_SERVICE_PROVIDER=custom
VPN_TYPE=wireguard
WIREGUARD_PRIVATE_KEY=<your-private-key>
WIREGUARD_ENDPOINT_IP=<server-ip>
WIREGUARD_ENDPOINT_PORT=51820
WIREGUARD_PUBLIC_KEY=<peer-public-key>
WIREGUARD_ADDRESSES=<assigned-tunnel-address>/32
one more thing specific to custom: it doesn’t support gluetun’s VPN_PORT_FORWARDING variable — leave it out entirely, or gluetun errors on startup. no automatic inbound-port sync this way; outbound still works fine.
a stale routing rule can block every future reconnectsilent, no error until you look
step 4 — verify the tunnel actually works
ip -s link show wg0
curl -s ifconfig.me
expect nonzero RX and TX, and an exit IP that isn’t your home WAN. TX-only with RX stuck at zero means the handshake is going out but nothing’s coming back — work through step 2’s gotcha before touching anything else.
step 5 — turn the gluetun container into an actual gateway
on the gluetun container:
apt install -y iptables # not preinstalled on the minimal community-scripts template
sysctl -w net.ipv4.ip_forward=1
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE
iptables -A FORWARD -o wg0 -j ACCEPT
iptables -A FORWARD -i wg0 -j ACCEPT
iptables -A FORWARD -j DROP
that last rule is the entire kill switch: nothing forwards through this container except via the tunnel interface. no tunnel, no forwarding, full stop — not a fallback to the container’s own normal route. note this is on top of gluetun’s own built-in firewall for its own traffic — this adds forwarding rules for traffic routed through it from the other containers, which is a separate concern.
step 6 — route qBittorrent and Prowlarr through gluetun
on the qBittorrent and Prowlarr containers:
ip route replace default via <gluetun-container-ip>
DHCP silently undoes this on every rebootun-tunnels with zero error
cat > /etc/network/if-up.d/vpn-gateway << 'EOF'
#!/bin/sh
if [ "$IFACE" = "eth0" ]; then
ip route replace default via <gluetun-container-ip>
fi
EOF
chmod +x /etc/network/if-up.d/vpn-gateway
(if-up.d is the ifupdown convention — use the equivalent hook for your container’s actual init/networking system if it’s not ifupdown-based.) confirmed this survives a full reboot: ip route | grep default and curl -s ifconfig.me both still correct after.
step 7 — prove the kill switch actually kills
# on the gluetun container
systemctl stop gluetun
# on the qBittorrent container
curl -s --max-time 5 ifconfig.me; echo "exit: $?"
should hang and time out (exit code 28), not return your real WAN IP. then bring it back and confirm auto-recovery with zero manual steps on the other containers’ end:
# on the gluetun container
systemctl start gluetun
# on the qBittorrent container, a few seconds later
curl -s ifconfig.me
step 8 — shared library storage
Radarr needs to see wherever your actual library lives — commonly an SMB share on a NAS or a file-serving box elsewhere on the network.
this is the step that breaks first, and the error is misleading. mounting an SMB share directly from inside an unprivileged container:
mount -t cifs //<file-server-address>/<library-share> /mnt/library \
-o username=<service-account>,password='<password>',uid=1000,gid=1000
mount error(1): Operation not permitted
nothing in that error mentions the real cause. two plausible-looking fixes exist that don’t actually fix it:
checked AppArmor first — necessary, not sufficientfalse lead #1
checked seccomp next — also not itfalse lead #2
the real cause — a kernel-level restriction, not a config settingactual fix
working sequence, on the host:
mkdir -p /mnt/host-cifs/library
mount -t cifs //<file-server-address>/<library-share> /mnt/host-cifs/library \
-o username=<service-account>,password='<password>',uid=<container-root-host-uid>,gid=<container-root-host-uid>
pct set <container-id> -mp0 /mnt/host-cifs/library,mp=/mnt/library
pct reboot <container-id>
the uid value matters and it’s not the number you’d guess. confirm the container’s actual mapping rather than assuming:
# on the host
cat /var/lib/lxc/<container-id>/config | grep idmap
# lxc.idmap = u 0 <base-offset> 65536
an unprivileged container’s root doesn’t map to host uid 0 — it maps to <base-offset> (commonly 100000 on a default single-host setup, but confirmed, not assumed). a file that should be owned by container-uid 1000 needs uid=<base-offset + 1000> on the host-side mount — not 1000 on its own. get this wrong and files show up owned by the wrong (or an entirely unmapped) user inside the container, without necessarily throwing an obvious error.
persist it — a host reboot without this drops the mount, and the bind-mount in the container just exposes an empty directory:
mkdir -p /etc/samba-credentials && chmod 700 /etc/samba-credentials
cat > /etc/samba-credentials/<service-account>.cred << 'EOF'
username=<service-account>
password=<password>
EOF
chmod 600 /etc/samba-credentials/<service-account>.cred
cat >> /etc/fstab << 'EOF'
//<file-server-address>/<library-share> /mnt/host-cifs/library cifs credentials=/etc/samba-credentials/<service-account>.cred,uid=<base-offset + 1000>,gid=<base-offset + 1000>,x-systemd.automount,_netdev 0 0
EOF
systemctl daemon-reload
mount -a
(_netdev waits for networking before mounting at boot; x-systemd.automount mounts on first access rather than blocking boot if the share’s briefly unreachable; the credentials file keeps the password out of world-readable fstab.)
step 9 — a shared downloads folder between qBittorrent and Radarr
qBittorrent needs somewhere to save files that Radarr can also see, to import from once a download finishes — otherwise Radarr reports something like “this directory does not appear to exist,” because it’s checking its own filesystem for a path that only exists on qBittorrent’s container. remote-path-mapping settings (below) only translate path strings between two views of the same underlying filesystem — they don’t grant filesystem access across two different machines on their own.
same host-mount-plus-bind-mount pattern as step 8, done twice — once into each container:
# on the host
mkdir -p /mnt/host-cifs/downloads
mount -t cifs //<file-server-address>/<downloads-share> /mnt/host-cifs/downloads \
-o username=<service-account>,password='<password>',uid=<base-offset>,gid=<base-offset>
pct set <qbittorrent-container-id> -mp0 /mnt/host-cifs/downloads,mp=/mnt/downloads
pct set <radarr-container-id> -mp1 /mnt/host-cifs/downloads,mp=/mnt/downloads
pct reboot <qbittorrent-container-id>
pct reboot <radarr-container-id>
cat >> /etc/fstab << 'EOF'
//<file-server-address>/<downloads-share> /mnt/host-cifs/downloads cifs credentials=/etc/samba-credentials/<service-account>.cred,uid=<base-offset>,gid=<base-offset>,x-systemd.automount,_netdev 0 0
EOF
systemctl daemon-reload && mount -a
two more things bite here specifically:
services can run as root inside their own containerdifferent uid math than you'd expect
ran the mount command inside the container by mistakewrong uid error, wrong root cause
point qBittorrent’s default save path at /mnt/downloads, and add a remote-path-mapping entry in Radarr: host = qBittorrent’s address, remote path = /mnt/downloads/, local path = /mnt/downloads/ — telling it the two containers’ identical-looking paths actually refer to the same files.
step 10 — wire the apps together
Radarr → Settings → Download Clients → Add qBittorrent: host = qBittorrent’s address, port = its actual WebUI port (check, don’t assume it matches whatever the qmcgaw/gluetun-adjacent Docker convention would default to — a native/systemd install commonly listens somewhere different), plus its WebUI credentials.
Prowlarr → Settings → Apps → Add Radarr: its own address (usually pre-filled), Radarr’s address, and an API key from Radarr’s own Settings → General → Security.
server-address fields default to localhostmeaningless across separate containers
forgot qBittorrent's WebUI passwordreset it, don't guess
step 11 — verify end to end
search for something in Radarr, confirm Prowlarr actually returns results, grab it, and watch it land in /mnt/downloads through the tunnel in qBittorrent. then confirm the last link: once the download finishes, Radarr should pick it up from /mnt/downloads and import it into /mnt/library automatically — no manual step. that’s the real proof the whole chain works, not just that each piece works in isolation.
step 12 — extending the stack: Sonarr, Lidarr, Bazarr
same one-container-per-service pattern as Radarr, no shortcuts for the “it’s basically the same app” instinct — each one gets its own community-scripts install and its own share, verified independently rather than assumed to work because Radarr did.
Sonarr and Lidarr follow step 8 (host-mount + bind-mount for their own library share) and step 10 (wire to the same qBittorrent + Prowlarr instances already running) exactly. No new gluetun or kill-switch work — they’re media-management apps, same as Radarr, so they never touch the tunnel.
lost the WebUI password? it's in config.xml, not a reset flow in the appsame fix across the whole *Arr family
Bazarr is a different role entirely — subtitle management, not media management. It only talks to Radarr and Sonarr (to know what to search for and where to write results); no download client, no indexer, no gluetun involvement at all.
Bazarr → Settings → Radarr: enable, Radarr’s address, Radarr’s API key. Bazarr → Settings → Sonarr: same pattern.
Bazarr's write requirement caught a bug that had been sitting in Radarr's mount since step 8surfaced by someone else's requirement, not by Radarr itself
verify the same way as step 11: search Sonarr/Lidarr for something real, confirm the grab→download→import chain completes through the same tunnel and shared-downloads path. for Bazarr, trigger a subtitle search/download on something already in the library and confirm the .srt actually lands on disk, not just that its connection tests pass:
find /mnt/library -iname "*.srt" -newermt "-10 minutes"
known gaps, on purpose
- no firewall-level kill switch — gluetun’s container-level one protects against the VPN dying, not against the gluetun container’s own networking faulting in some other way. a real VLAN with a firewall-level kill switch, per a real vpn kill switch, covers that gap — it’s the eventual target, not a rejection of this approach.
- flat network, not VLAN-isolated — these containers aren’t network-separated from anything else on the LAN the way a dedicated VLAN would isolate them.
both are accepted for now, not overlooked — limitation introduced by the lack of a managed switch port or spare router-capable box. gluetun as a container-level kill switch is a stopgap until the hardware exists to do it properly, not a permanent design choice.