vinnymarquez.dev/tutorials/wiring up a vpn-gated *arr stack
August 7, 202611 min readguide#homelab#proxmox#vpn#self-hosted

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.

download client /indexer containersdefault routegateway containerNAT + kill switch(iptables FORWARD)tunnel upVPN providerexittunnel downdroppedno fallback to real WAN*Arr app containers(no indexer/torrent traffic)normal LANgateway
only the containers that actually originate indexer or torrent traffic route through the gateway. the app frontends stay on the normal LAN gateway — they don't need tunneling, and tunneling them anyway would just add a hop for no reason.

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
checked Proxmox's own web UI for a toggle and there isn't one, despite what an older guide implied. it's always a container-config edit from the host shell, whether done for you by the install script or by hand — never something you'll find in the web UI's own device-passthrough settings.

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
spent a while assuming a broken tunnel was a config problem before realizing the actual server was unresponsive. confirmed with: systemctl restart gluetun, then ip -s link show wg0 — TX counted up (handshake sent), RX stayed at zero (nothing came back), every retry. swapped in a completely different server, on the same box, no other changes: handshook within 15 seconds, RX and TX both nonzero. if several servers in the same region all fail identically, check whether they share a peer public key in their downloaded configs — that's a sign of an account-side region outage, not a per-server issue, and trying yet another server in that region won't help.

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
gluetun as a native systemd service doesn't get a fresh network namespace on every restart the way stopping/starting the whole container would. an interrupted connection attempt leaves a routing-table rule behind that blocks every subsequent attempt: "adding ip rule 101: ... file exists". clear it, both stacks, before retrying: ip rule del pref 101 table 51820 — and ip -6 rule del pref 101 table 51820 for the v6 side. then systemctl restart gluetun; the backoff timer resets on restart so you don't have to wait out a growing retry delay.

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
if these containers get their address via DHCP, a reboot re-runs DHCP, which resets the default route right back to the normal LAN gateway. fix with a hook that reapplies the route every time the interface comes up, not just once by hand:
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
the documented fix for exactly this symptom: pct set <container-id> -features keyctl=1,nesting=1,mount=cifs (keep whatever features were already set, don't overwrite them), then a full stop/start — pct stop <container-id> && pct start <container-id>, not an in-guest reboot, which doesn't reliably regenerate the container's security profile the same way. confirmed via cat /var/lib/lxc/<container-id>/config that this generated the correct rule (lxc.apparmor.raw = mount fstype=cifs,), and journalctl -k on the host showed no AppArmor DENIED line for the mount attempt at all. real and correct — but doing it alone didn't fix the mount. failed identically afterward.
checked seccomp next — also not itfalse lead #2
cifs mounting needs a couple of kernel syscalls (keyctl/add_key/request_key) for its own internal key-management mechanism, and container seccomp profiles can silently EPERM those with no audit log, unlike AppArmor. checked directly: cat /var/lib/lxc/<container-id>/rules.seccomp — it's a denylist (default-allow, only listed syscalls blocked), and none of the relevant syscalls were on it. ruled out too.
the real cause — a kernel-level restriction, not a config settingactual fix
the cifs filesystem type has no FS_USERNS_MOUNT support in the kernel — it cannot be mounted from inside a Linux user namespace at all, which is exactly what every unprivileged container runs in. no AppArmor rule, no seccomp allowance, nothing at the container-config level changes this. the fix: mount the share on the Proxmox host instead (not namespaced, unaffected by the restriction), then bind-mount that already-mounted path into the container — a bind-mount of existing host content doesn't hit the same restriction.

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
checked which OS user each service actually runs as before assuming a uid-1000-style service account: systemctl show qbittorrent-nox -p User --value (and the same for radarr), run inside each container. empty output means the user is unset — root. both came back empty here. root inside an unprivileged container maps to the container's own base offset on the host directly (uid=<base-offset>), not base-offset-plus-1000 like step 8's library mount used. check per-service, don't assume the same math carries over from a different share you mounted earlier.
ran the mount command inside the container by mistakewrong uid error, wrong root cause
on a first attempt, ran the cifs mount command directly inside qBittorrent's own container instead of on the host — got "mount error(1): Invalid uid '<base-offset>'", which reads like a typo but isn't. an unprivileged container's own namespace only resolves uids in its own limited internal range (0–65535); a host-side offset number like <base-offset> literally has no meaning from inside the container's own perspective. the mount command belongs on the host, always — same restriction as step 8, easy to forget once you're a few steps removed from where you first hit it.

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
an address field that defaults to "localhost" only means anything if both apps share a machine. across separate containers it needs each app's actual network address — check every pre-filled default before trusting it, especially right after restoring either app from a backup: a restored app can carry cross-references to other apps from whatever topology existed when that backup was taken, not the one it's running in now. if addresses look right in Prowlarr's own app-connection config but synced indexer entries in Radarr still show localhost afterward, delete the stale synced entries in Radarr and force a re-sync from Prowlarr's side (Indexers → Sync App Indexers), rather than hand-editing them — a pre-existing entry with a matching name doesn't always get cleanly overwritten in place.
forgot qBittorrent's WebUI passwordreset it, don't guess
a native systemd qBittorrent install generates a fresh temporary password on every startup, printed to its service log, until a real one is explicitly saved once: journalctl -u qbittorrent-nox -n 50 --no-pager | grep -i "temporary password". if that comes back empty because it's been running a while past first boot, force a fresh one: systemctl stop qbittorrent-nox, then strip the stored password hash from its config file (find / -name qBittorrent.conf to confirm the path, then remove the line starting WebUI\Password_PBKDF2=), systemctl start qbittorrent-nox, and the temp-password message reappears in the log. log in with admin + that temp password, then immediately set a real one under Tools → Options → WebUI → Authentication so it stops rotating.

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
Radarr/Sonarr/Lidarr each store their own login in config.xml, gated by an AuthenticationMethod field — there's no per-app 'forgot password' flow in the UI. find the real path first, don't guess it (varies by install method): find / -iname config.xml 2>/dev/null. then: systemctl stop sonarr, flip auth off, restart, log in without a password, and immediately re-enable auth with a new one from the UI — systemctl stop sonarr && sed -i 's/<AuthenticationMethod>.*<\/AuthenticationMethod>/<AuthenticationMethod>None<\/AuthenticationMethod>/' <config-path> && systemctl start sonarr.

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
Radarr's connection to its own library share worked fine for weeks — reading files it already knew about never needed write access, so a wrong uid never showed up as a problem. Bazarr connecting to the same share to write .srt files failed immediately with a permissions error, and that's what surfaced it: step 8's original mount used uid=<base-offset + 1000> (assuming a uid-1000-style service account), but systemctl show radarr -p User --value came back empty — root, same as qBittorrent in step 9 — meaning the mount should have used uid=<base-offset> all along. fixed at the source: unmounted and remounted the share with the corrected uid, rather than giving Bazarr its own special-cased workaround mount. verified with a real write, not just a connection test — triggered a subtitle download, then checked the library mount from Bazarr's own console for a freshly-modified .srt file sitting next to the video.

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

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.

▌ comments