DEV Community

david
david

Posted on Originally published at woitzik.dev

My Media Stack Lives in Two Containers and a Python CronJob

Originally published at woitzik.dev

Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.

My media acquisition stack β€” SABnzbd for Usenet downloads, Sonarr for TV, Radarr for movies, Bazarr for subtitles, NZBHydra2 for indexer search β€” used to run as k3s Deployments. It worked, but three problems made it a bad fit for Kubernetes: GPU passthrough for Jellyfin transcoding, per-flow traffic isolation for indexer queries versus the actual download path, and the NFS file-locking trap for media libraries.

The solution: move the media stack out of k3s entirely. Jellyfin runs in its own GPU-passthrough LXC. The acquisition stack runs in a second LXC with Docker Compose. A Python CronJob in k3s bridges the two via Traefik Service+Endpoints.

Update: the first version of this stack wrapped the whole acquisition LXC in a Mullvad WireGuard tunnel via gluetun, on the assumption that every flow out of that box needed VPN protection equally. Two days later I tore that back out β€” see "The Traffic-Isolation Rethink" below for why a single blanket tunnel was the wrong model for what these five apps actually do.

View the complete homelab infrastructure source on GitHub πŸ™

Why Kubernetes Was Wrong for Media

GPU Passthrough

Jellyfin needs GPU access for hardware video transcoding. The BMAX Mini PC* has an AMD Radeon Vega iGPU that supports VAAPI hardware transcoding. Proxmox GPU passthrough requires IOMMU group isolation β€” the GPU is passed to a single container or VM exclusively.

Kubernetes doesn't natively support GPU passthrough for LXCs. The nvidia-device-plugin works for NVIDIA GPUs on specific cloud providers, but for AMD iGPU passthrough on bare-metal Proxmox, you need a dedicated LXC with /dev/dri/renderD128 mapped directly.

Jellyfin runs in ct-srv-jellyfin-01 with:

# terraform/stacks/proxmox/lxc.tf
lxc_conf {
  desc = "Jellyfin - GPU passthrough"
  # GPU device mapped via pct set
}
Enter fullscreen mode Exit fullscreen mode

k3s's own nodes here (vm-srv-k3s-11/12/13) are Proxmox VMs, not LXCs β€” a VM doesn't share the host kernel, so there's no "just bind-mount the device node" path the way there is for an LXC. Getting a k3s pod real GPU access on this hardware would mean classic VFIO passthrough of the iGPU to one specific VM: unbinding amdgpu from the Proxmox host and handing the whole device to vfio-pci instead. I looked at this seriously before ruling it out, because "GPU-in-Kubernetes" device plugins exist and I wanted to know if they'd apply.

They don't, for this hardware. AMD's own rocm/k8s-device-plugin targets ROCm compute (HIP/OpenCL) β€” a materially heavier stack than what VAAPI hardware transcoding actually needs, which is just /dev/dri visibility. And a Kubernetes device plugin can't manufacture GPU access a node's kernel doesn't already have β€” for a VM, that access only exists after real hypervisor-level VFIO passthrough, which a plugin doesn't do. Worse, this is a single consumer Ryzen APU, not a data-center part with SR-IOV or mediated-device support for splitting one GPU across VMs β€” passthrough would hand the entire GPU to exactly one of the three k3s VMs, and the Proxmox host itself (which uses amdgpu for its own display/telemetry) would permanently lose access to it. There's also no portability payoff to offset that cost: k3s's scheduler can't move a pod needing a passed-through device to a different node than the one VFIO was bound to, so the usual "GPU follows the pod" reason people move transcoding into Kubernetes never materializes on a single-host, single-iGPU homelab. It would just be Jellyfin running on a VM instead of an LXC, at the permanent cost of the GPU being unavailable to anything else on the host.

The LXC path avoids all of that: an LXC shares the host's kernel, so the host keeps the amdgpu driver bound and simply grants the container access to the resulting /dev/dri/renderD128 device node. Non-exclusive from the host's perspective, already proven working, no PCI device binding to get wrong. If this box ever gets a GPU with real SR-IOV support, this is worth revisiting β€” the constraint here is the specific hardware, not a principled objection to GPU workloads in Kubernetes.

The Traffic-Isolation Rethink

The acquisition LXC has two genuinely different outbound flows, and my first pass treated them as one problem. SABnzbd connects to Eweka (my Usenet provider) over NNTPS on port 563 β€” already encrypted end-to-end, and Usenet copyright enforcement works exclusively via BitTorrent peer-list monitoring, so there's no mechanism by which an ISP or rights-holder observes or reports Usenet downloads in the first place. NZBHydra2's indexer search queries are a completely different flow: plain HTTP/HTTPS lookups against third-party indexer sites, which does expose the home IP to whoever's on the other end, the same as browsing any site directly.

Wrapping the whole LXC in gluetun/Mullvad "solved" both at once, but it was the wrong tool for either: VPN on the download path halves throughput for no privacy benefit Eweka's own SSL doesn't already provide, and it risks Eweka flagging the account for apparent multi-subscriber IP sharing. I tore gluetun out two days after standing it up and replaced it with a model that actually matches the two flows:

  • SABnzbd β†’ Eweka, direct, over SSL. No VPN. The ISP sees "connected to news.eweka.nl," never content β€” that's the whole job done by NNTPS alone.
  • NZBHydra2 β†’ indexers, through a Tor SOCKS5 proxy, tor container at 172.28.1.10:9050, configured with no direct-connection fallback. Search queries are small and latency-tolerant, a good fit for Tor's limited bandwidth, and Tor is a better fit than a commercial VPN for metadata-only queries β€” no single operator to trust, no throughput to throttle. If Tor is down, indexer queries fail outright instead of silently leaking the home IP.

I verified this configuration directly rather than trusting the design intent on paper: sabnzbd.ini shows socks5_proxy_url = "" (empty β€” SABnzbd was never routed through Tor) with ssl = 1, ssl_verify = 2 confirming the direct-to-Eweka SSL path is real; nzbhydra.yml shows proxyType: SOCKS pointed at the Tor container with no fallback option enabled. One live exception I noticed and haven't chased down yet: one specific indexer bypasses Tor and connects directly (proxyIgnoreDomains) β€” possibly a site that blocks Tor exit nodes, possibly a leftover exception from before I understood this stack properly. Worth revisiting.

Tor's bandwidth genuinely can't handle bulk transfers, and routing downloads through it would be abusive to a network that exists for people who need anonymity for safety β€” never route the actual download path through Tor, only small metadata lookups.

The acquisition LXC (ct-srv-media-acq-01) runs all five apps this way β€” no blanket tunnel, no kill-switch sidecar to maintain, no shared failure mode between "is Eweka's SSL up" and "is the VPN provider's WireGuard endpoint reachable today."

NFS File Locking

Media libraries (downloaded files, metadata databases) live on NFS. As I wrote about in the SQLite trap article, NFS file-locking semantics don't work with embedded databases. Sonarr and Radarr use SQLite internally for their media databases β€” and those databases corrupt on NFS under concurrent access.

Moving the acquisition stack to a local LXC with local storage eliminates the NFS lock problem. The media files themselves (downloaded episodes, movies) still live on NFS for sharing, but the application databases stay local.

The Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  k3s Cluster                                β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚ Python CronJob (every 10 min)       β”‚    β”‚
β”‚  β”‚ - Checks Sonarr/Radarr queue        β”‚    β”‚
β”‚  β”‚ - Clears stuck items                β”‚    β”‚
β”‚  β”‚ - Triggers Jellyfin library scan    β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚             β”‚ HTTP via Traefik              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚ Traefik IngressRoutes               β”‚    β”‚
β”‚  β”‚ - sabnzbd.woitzik.dev               β”‚    β”‚
β”‚  β”‚ - sonarr.woitzik.dev                β”‚    β”‚
β”‚  β”‚ - radarr.woitzik.dev                β”‚    β”‚
β”‚  β”‚ - bazarr.woitzik.dev                β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   β”‚ Traefik Service+Endpoints
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ct-srv-media-acq-01 (LXC, Tor for indexers)β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”         β”‚
β”‚  β”‚ SABnzbd β”‚ β”‚ Sonarr β”‚ β”‚ Radarr β”‚         β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”              β”‚
β”‚  β”‚ Bazarr  β”‚ β”‚ NZBHydra2    β”‚              β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ct-srv-jellyfin-01 (LXC, GPU passthrough)  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”              β”‚
β”‚  β”‚ Jellyfinβ”‚ β”‚ /dev/dri/    β”‚              β”‚
β”‚  β”‚         β”‚ β”‚ renderD128   β”‚              β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

The Traefik Bridge

k3s Traefik routes external traffic to services inside the cluster. But the media stack isn't in the cluster β€” it's in LXCs. The bridge: Traefik IngressRoutes point at Kubernetes Services, which use Endpoints objects with hardcoded IP addresses pointing at the LXC containers.

# kubernetes/apps/jellyfin/jellyfin.yml
apiVersion: v1
kind: Service
metadata:
  name: jellyfin
  namespace: apps
spec:
  ports:
    - port: 8096
      targetPort: 8096
---
apiVersion: v1
kind: Endpoints
metadata:
  name: jellyfin
  namespace: apps
subsets:
  - addresses:
      - ip: 10.0.20.254  # ct-srv-jellyfin-01
    ports:
      - port: 8096
Enter fullscreen mode Exit fullscreen mode

This is the same pattern for all external services. The Service has no selector β€” it's a "headless" Service where the Endpoints are manually maintained. Traefik doesn't know or care that the backend is an LXC instead of a pod.

The IngressRoute for Jellyfin:

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: jellyfin
  namespace: apps
spec:
  entryPoints: [websecure]
  routes:
    - match: Host(`media.woitzik.dev`)
      kind: Rule
      middlewares: [{name: authelia}]
      services:
        - name: jellyfin
          port: 8096
  tls:
    secretName: wildcard-woitzik-dev-tls
Enter fullscreen mode Exit fullscreen mode

The media stack gets Authelia protection, wildcard TLS, and Cloudflare Tunnel external access β€” the same as every in-cluster service. The only difference is the backend IP.

The Python CronJob

A Python CronJob runs every 10 minutes inside k3s, bridging the gap between the media stack and the cluster:

# CronJob that monitors media acquisition
schedule: "*/10 * * * *"
jobTemplate:
  spec:
    template:
      spec:
        containers:
          - name: media-watchdog
            image: python:3.12-slim
            command:
              - python
              - /scripts/media-watchdog.py
Enter fullscreen mode Exit fullscreen mode

The script:

  1. Queries Sonarr/Radarr APIs for stuck queue items (downloads stuck for >30 minutes)
  2. Clears stuck items and re-triggers the download
  3. Checks Jellyfin's library scan status
  4. Posts status to Discord via webhook

Without this CronJob, stuck downloads sit indefinitely. Sonarr and Radarr don't have built-in queue monitoring β€” they trust the download client to report status, and SABnzbd sometimes silently fails without notifying the *arr stack.

What I'd Change

  1. Use a proper service mesh for cross-boundary traffic. The manual Endpoints pattern works but is fragile β€” if the LXC IP changes, the Endpoints must be updated manually. A DNS-based service discovery (Headscale DNS entries, for example) would be more resilient.

  2. Move the CronJob to a native LXC cron. The Python script runs in a k3s Pod but talks to LXC-hosted services. It has no business being in the cluster. A systemd timer on the media acquisition LXC would be simpler.


The media stack's departure from Kubernetes is the same pattern as running stateful workloads outside AKS: GPU workloads go to dedicated VMs with GPU passthrough, traffic-sensitive workloads get network-level isolation instead of a sidecar, and file-locking workloads go to local SSDs. Kubernetes excels at stateless, horizontally-scalable workloads. Media transcoding and acquisition are neither β€” they're stateful, single-instance, and hardware-dependent. The right platform for them is the bare metal underneath, not the orchestration layer on top.

Top comments (0)