Gluetun + Arr Stack
Gluetun + Arr Stack
VPN-gated download and media-management stack. A single Kubernetes Pod runs Gluetun (WireGuard/OpenVPN client) as a native sidecar alongside qBittorrent, Sonarr, Radarr, Prowlarr, and Profilarr — all five app containers share Gluetun’s network namespace, so every byte of their traffic (search, download, API calls) exits through the VPN tunnel with no extra configuration on their end.
Architecture
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Traefik (websecure :443)
│
┌──────────────┬──────────┼──────────┬──────────────┐
│ │ │ │ │
qbittorrent. sonarr. radarr. prowlarr. profilarr.
prod-k3s... prod-k3s... prod-k3s... prod-k3s... prod-k3s...
│ │ │ │ │
└──────────────┴──────────┴──────────┴──────────────┘
│
Service: gluetun (ClusterIP)
ports 8081 / 8989 / 7878 / 9696 / 6868
│
══════════════════ one Pod, one network namespace ══════════════════
│ │
│ initContainer (native sidecar): gluetun │
│ - WireGuard/OpenVPN client (Surfshark) │
│ - NET_ADMIN capability only (no hostNetwork) │
│ - startupProbe: `ip link show tun0` gates everything below │
│ │
│ containers (start only once gluetun's tun0 is up): │
│ - qbittorrent :8081 (config PVC + NFS /downloads) │
│ - sonarr :8989 (config PVC + NFS /tv /anime /anime2 │
│ /downloads) │
│ - radarr :7878 (config PVC + NFS /data/{Movies,Anime, │
│ Anime2,AnimeMovies,Download}) │
│ - prowlarr :9696 (config PVC only, no media) │
│ - profilarr :6868 (config PVC only, no media) │
═════════════════════════════════════════════════════════════════════
│
NFS: 192.168.1.194:/media_pool
(same export used by the Media Indexer stack)
Why one Pod
This mirrors the old Docker Compose setup’s network_mode: service:gluetun: containers in the same Kubernetes Pod always share a network namespace, so qbittorrent/sonarr/ radarr/prowlarr/profilarr binding their web UIs here are reachable only via Gluetun’s routing — all egress goes through the VPN tunnel Gluetun brings up.
Gluetun runs as a native sidecar (initContainer + restartPolicy: Always, supported since k8s 1.29 / this cluster’s k3s 1.30.2): it starts first, and because it carries a startupProbe, none of the app containers start until that probe passes — i.e. until the VPN tunnel is actually up. This reproduces the guarantee the old Compose file got from depends_on: condition: service_healthy.
NET_ADMIN is the only elevated permission Gluetun needs in Kubernetes — unlike the old Compose file’s explicit /dev/net/tun device mapping, the kernel creates /dev/net/tun automatically per network namespace.
Container / Role Summary
| Container | Image | Port | Storage | Notes |
|---|---|---|---|---|
gluetun (initContainer) | qmcgaw/gluetun:latest | — | 200Mi PVC (/gluetun) | Native sidecar, NET_ADMIN only |
qbittorrent | lscr.io/linuxserver/qbittorrent:latest | 8081 | 2Gi PVC + NFS Download → /downloads | Torrent client |
sonarr | lscr.io/linuxserver/sonarr:latest | 8989 | 2Gi PVC + NFS TV/Anime/Anime2/Download | TV library management |
radarr | lscr.io/linuxserver/radarr:latest | 7878 | 10Gi PVC + NFS Movies/Anime/Anime2/AnimeMovies/Download | Movie library management |
prowlarr | lscr.io/linuxserver/prowlarr:latest | 9696 | 2Gi PVC, no media mount | Indexer manager (feeds Sonarr/Radarr) |
profilarr | ghcr.io/dictionarry-hub/profilarr:latest | 6868 | 2Gi PVC, no media mount | Regex/custom-format & quality-profile manager |
All five app containers read shared, non-secret env vars from the servarr-common-env ConfigMap (TZ=America/New_York, PUID=0, PGID=0). Radarr additionally reads radarr-auth (a Secret) to enable Forms-auth credentials via RADARR_ADMIN_USERNAME/ RADARR_ADMIN_PASSWORD — set directly through Radarr’s /api/v3/config/host API rather than the (non-functional) RADARR__AUTH__USERNAME/PASSWORD env vars, since Radarr computes and stores a PBKDF2 hash on save rather than accepting a plaintext config field.
Storage
- Config: one Longhorn-backed
ReadWriteOncePVC per app (gluetun-config200Mi,qbittorrent-config/sonarr-config/prowlarr-config/profilarr-config2Gi,radarr-config10Gi — bumped from 2Gi afterMediaCoverfilled the volume, see Incidents below). - Media: a single NFS export (
192.168.1.194:/media_pool, the same server/export the Media Indexer stack uses) mounted once per Pod and split per-directory viasubPath—Downloadis mounted at the same in-container path (/downloadsfor qbittorrent/ sonarr,/data/Downloadfor radarr) so completed downloads import/hardlink onto the same filesystem instead of a slow cross-filesystem copy.
VPN Configuration
Provider: Surfshark, via WireGuard/OpenVPN (both supported, see Incidents). Config lives in the gluetun-env Secret — key names only, values never leave the cluster:
VPN_SERVICE_PROVIDER, VPN_TYPE, OPENVPN_USER, OPENVPN_PASSWORD, WIREGUARD_PUBLIC_KEY, WIREGUARD_PRIVATE_KEY, WIREGUARD_ADDRESSES, SERVER_COUNTRIES, SERVER_CITIES, SERVER_HOSTNAMES, FIREWALL_VPN_INPUT_PORTS, FIREWALL_INPUT_PORTS, FIREWALL_OUTBOUND_SUBNETS, WIREGUARD_MTU, WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL, OPENVPN_MSSFIX, HEALTH_RESTART_VPN, HEALTH_VPN_DURATION_INITIAL (now a no-op, see below).
FIREWALL_OUTBOUND_SUBNETS is set to the full pod CIDR (10.42.0.0/16) — without it, Gluetun’s tunnel-as-default-route would swallow reply traffic addressed back to Traefik whenever it arrives over the flannel VXLAN overlay from a different node than Gluetun’s.
Health Checks
startupProbe/livenessProbe on the gluetun initContainer run ip link show tun0 rather than Gluetun’s own /gluetun-entrypoint healthcheck. This is deliberate — see the root-cause writeup below.
Ingress
Each app has its own Traefik IngressRoute (in its own directory: ../Qbittorrent, ../Sonarr, ../Radarr, ../Prowlarr, ../Profilarr) all pointing at the single gluetun Service on their respective port, TLS via local-example-com-tls:
| Host | → Service port |
|---|---|
qbittorrent.prod-k3s.iot-proxmox.net | gluetun:8081 |
sonarr.prod-k3s.iot-proxmox.net | gluetun:8989 |
radarr.prod-k3s.iot-proxmox.net | gluetun:7878 |
prowlarr.prod-k3s.iot-proxmox.net | gluetun:9696 |
profilarr.prod-k3s.iot-proxmox.net | gluetun:6868 |
strategy: Recreate means the entire six-container Pod restarts on any deployment.yaml apply — a change to one app briefly takes down all of them.
Incidents & Lessons Learned
The rollout readme documents a genuinely difficult investigation worth preserving here.
“Handshake completes, then no data flows.” Both WireGuard and OpenVPN would connect successfully (Initialization Sequence Completed, valid public IP) but all subsequent traffic silently vanished, causing Gluetun’s own healthcheck to restart the tunnel every ~8 seconds indefinitely. Over several days this was tested against, and ruled out:
- Router/firewall device-level blocks (checked, clean)
- Host
iptablespolicy — actually found and fixed a real, separate bug: k3s’s embedded kube-router network-policy controller hadDROPas the default chain policy on two of three nodes (agents),ACCEPTon the third (server) — fixed withiptables -P {INPUT, FORWARD,OUTPUT} ACCEPT, not policy-persisted, worth re-checking after a node reboot - MTU/MSS mismatch under VXLAN double-encapsulation (tuned
WIREGUARD_MTU,OPENVPN_MSSFIX; no effect) - DNS resolver type (DoT vs. DoH vs. plain; no effect)
- CNI/overlay routing, conntrack/SNAT, node placement (agent vs. server),
rp_filter(all checked directly inside the pod netns; all clean)
Root cause: none of the above. Packet captures on tun0 during a live “failing” window showed real, substantial data actually flowing (a manual curl succeeding, and Gluetun’s own blocklist download completing) at the exact moment Gluetun’s healthcheck logged a timeout and restarted the tunnel. This is a known upstream bug (qdm12/gluetun#2942): Gluetun’s DNS-over-TLS healthcheck opened a new TCP connection per query, and under this cluster’s extra layer of NAT/conntrack (pod → node → home router), that churn produced false failures against an otherwise perfectly healthy tunnel.
Fix: HEALTH_RESTART_VPN: "off" to disable Gluetun’s internal auto-restart, paired with Kubernetes probes pointed at a directly-verified signal (ip link show tun0) instead of Gluetun’s unreliable one. Result: zero VPN restarts since.
The hostNetwork: true incident. Tried as a troubleshooting step, this was reverted for good after it caused two real outages of the Cloudflare tunnel — with hostNetwork: true, Gluetun modifies the node’s real routing table on every failed-handshake retry, and both incidents happened because Cloudflare’s tunnel pod was scheduled on the same node. Plain pod networking keeps every route/iptables change Gluetun makes scoped to its own Pod’s network namespace — it’s structurally impossible for it to affect any other pod or the node itself.
Radarr disk-full crash loop (2026-08-28). After an unrelated Recreate restart, Radarr crash-looped with AppFolder /config is not writable. Root cause: its 2Gi config PVC was at 100% — MediaCover (poster/fanart cache, which scales with library size) alone was 1.8G. Fixed with a live Longhorn volume expansion to 10Gi (allowVolumeExpansion: true, no pod deletion needed). sonarr-config/prowlarr-config remain at 2Gi and are flagged as at risk of the same failure mode if their own caches grow.