Post

Gluetun + Arr Stack

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

ContainerImagePortStorageNotes
gluetun (initContainer)qmcgaw/gluetun:latest200Mi PVC (/gluetun)Native sidecar, NET_ADMIN only
qbittorrentlscr.io/linuxserver/qbittorrent:latest80812Gi PVC + NFS Download → /downloadsTorrent client
sonarrlscr.io/linuxserver/sonarr:latest89892Gi PVC + NFS TV/Anime/Anime2/DownloadTV library management
radarrlscr.io/linuxserver/radarr:latest787810Gi PVC + NFS Movies/Anime/Anime2/AnimeMovies/DownloadMovie library management
prowlarrlscr.io/linuxserver/prowlarr:latest96962Gi PVC, no media mountIndexer manager (feeds Sonarr/Radarr)
profilarrghcr.io/dictionarry-hub/profilarr:latest68682Gi PVC, no media mountRegex/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 ReadWriteOnce PVC per app (gluetun-config 200Mi, qbittorrent-config/sonarr-config/prowlarr-config/profilarr-config 2Gi, radarr-config 10Gi — bumped from 2Gi after MediaCover filled 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 via subPathDownload is mounted at the same in-container path (/downloads for qbittorrent/ sonarr, /data/Download for 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.netgluetun:8081
sonarr.prod-k3s.iot-proxmox.netgluetun:8989
radarr.prod-k3s.iot-proxmox.netgluetun:7878
prowlarr.prod-k3s.iot-proxmox.netgluetun:9696
profilarr.prod-k3s.iot-proxmox.netgluetun: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 iptables policy — actually found and fixed a real, separate bug: k3s’s embedded kube-router network-policy controller had DROP as the default chain policy on two of three nodes (agents), ACCEPT on the third (server) — fixed with iptables -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.

This post is licensed under CC BY 4.0 by the author.