This post is outdated or has been replaced. It stays online for reference; the setup it describes is not what I run today.
Warning
Archived. This setup ran on k3s, which I’ve since replaced with Talos + FluxCD GitOps. Keeping this post up as a reference for the manifest structure and NFS setup on Kubernetes.
This stack runs fine in Docker Compose. I moved it to Kubernetes because everything else already was, and running one thing differently from everything else costs more than the migration did — volumes, health checks and restarts all behave the same way as the rest of the cluster.
Two storage classes underneath: app configs on Longhorn, media on a Synology DS223 exported over NFS 4.1 as a PersistentVolume. Configs are small and want replication; 400GB of media does not.
This has to exist before anything on the Kubernetes side tries to mount it. A PV pointing at an export that doesn’t permit the node stays Pending with no useful message. The Synology rule I use:
One per app, 5Gi each. These hold the databases the *arr apps refuse to rebuild — app-config-pvc.yaml:
apiVersion:v1kind:PersistentVolumeClaimmetadata:name:app# radarr for examplenamespace:mediaspec:accessModes:- ReadWriteOncestorageClassName:longhornresources:requests:storage:5Gi
kubectl apply -f app-config-pvc.yaml
Caution
One PVC per application — Jellyfin, Sonarr, Radarr, Prowlarr and qBittorrent. Sharing one between two of them corrupts both databases, not just the second.
qBittorrent v5 renamed the API endpoints /torrents/pause and /torrents/resume to /torrents/stop and /torrents/start. If you use any scripts or integrations that call the qBittorrent API directly, update them before upgrading from v4.
Use this instead of the one above, not alongside it. Gluetun is a sidecar in the same pod, so it shares the network namespace — qBittorrent has no route to the internet except through the tunnel, which is the point. If Gluetun dies, qBittorrent loses connectivity rather than falling back to your real IP.
I use SurfShark with WireGuard — faster than OpenVPN and natively supported by Gluetun. Generate your WireGuard key from the SurfShark dashboard under VPN → Manual Setup → WireGuard. Note: SurfShark does not support port forwarding, so peers cannot initiate inbound connections — downloads still work fine but may be slower without seeding peers.
Each app needs a ClusterIP service before Traefik has anything to route to. One per application, app-service.yaml — mind that targetPort differs per app while port stays 80:
apiVersion:v1kind:Servicemetadata:name:app# radarr for example namespace:mediaspec:type:ClusterIPports:- port:80targetPort:7878selector:app:app# radarr for example
apiVersion:traefik.io/v1alpha1kind:IngressRoutemetadata:name:app# radarr for example namespace:mediaannotations:kubernetes.io/ingress.class:traefik-externalspec:entryPoints:- websecureroutes:- match:Host(`movies.merox.cloud`)# change to your domainkind:Ruleservices:- name:app# radarr for example port:80- match:Host(`movies.merox.cloud`)# change to your domainkind:Ruleservices:- name:app# radarr for example port:80middlewares:- name:default-headers-mediatls:secretName:mycert-tls# change to your cert name
kubectl apply -f app-ingress-route.yaml
Caution
The hostname in the IngressRoute has to resolve before you apply it. Traefik will happily accept a route for a name nothing can look up, and the failure surfaces in the browser rather than in kubectl.
One thing to change if you build on this: the media PV is declared ReadWriteOnce while five deployments mount it. That works only as long as every pod lands on the same node, and it stops working the first time the scheduler disagrees. NFS is ReadWriteMany — declare it that way and the constraint disappears.