The Kubernetes stack
The cluster half is standard tenant-0 GitOps: base manifests under
k8s/manifests/base/appliations/, a media namespace overlay, and a single Flux
Kustomization (applications-media) wired into the applications entrypoint.
Storage
Section titled “Storage”media-data— a static RWXPersistentVolumeusing the in-treenfsvolume type (server: 10.50.0.31,path: /data,nfsvers=4.1,hard,noatime) and a bound PVC. Every media-touching pod (Sonarr, Radarr, Bazarr, Jellyfin) mounts it at/data. Do not split it per-app withsubPath— Kubernetes treats asubPathas a different filesystem and Sonarr can’t hardlink across it.<app>-config— per-appceph-blockRWO PVCs for SQLite/state (and Jellyfin’s transcode temp). SQLite over NFS corrupts, so config never touches NFS.
NZBGet is off-cluster
Section titled “NZBGet is off-cluster”NZBGet runs on the VM, so media-data/nzbget-endpoint.yaml is a selectorless
Service + manual EndpointSlice pointing at the VM (10.50.0.31:6789). That gives
the *arr apps a stable nzbget.media.svc:6789 name. Keep the address in sync with
the Terraform vm_ip.
Pod security
Section titled “Pod security”The media namespace is pod-security.kubernetes.io/enforce: baseline. The
linuxserver images (prowlarr/sonarr/radarr/bazarr/jellyfin) start as root
and drop to PUID/PGID 1000 via s6, so they can’t satisfy restricted — do not
add runAsNonRoot to them.
Seerr, as a Deployment. Seerr (image
seerr/seerr, the community continuation of Jellyseerr/Overseerr) has no maintained Helm chart, so it ships as a plain Deployment like the other apps. It auto-migrates from Overseerr/Jellyseerr on first start — back up/app/configfirst.
External access & SSO
Section titled “External access & SSO”Every externally-exposed media app attaches to one shared Gateway
(k8s/manifests/base/appliations/media-gateway/, rendered into the media namespace).
It carries one HTTPS listener per hostname — jellyfin, jellyfin-requests (Seerr),
radarr, sonarr, bazarr — each with its own cert-manager cert. Each app keeps its
own HTTPRoute and attaches via parentRefs.sectionName. Add a new exposed app by
adding a listener here plus a route in the app’s dir — no new Gateway object.
Radarr, Sonarr and Bazarr sit behind an oauth2-proxy sidecar (Jellyfin/Seerr do
their own auth and are exposed directly). Per app:
- The public route’s backend is the sidecar (
port 4180), not the app. oauth2-proxy runs OIDC against Authentik, then proxies to127.0.0.1:<app-port>. The *arr apps run inAuthenticationMethod=External+DisabledForLocalAddressesand Bazarr with its own auth disabled, so each trusts the local proxy. Internal wiring (Prowlarr, the wiring Job) keeps hitting the app port directly with the API key, bypassing the proxy. - Each app is its own Authentik OIDC client (
tf/application-parameters/authentik,module.application_radarr/module.application_sonarr/module.application_bazarr), gated to thehomelab__mediagroup via a policy binding. The module writesclient_id/client_secretto Vault atsecrets/oidc/<slug>. - The sidecar reads three secrets via
vault-secrets-operator(sharedmedia-secretsVault auth):client_id+client_secretfromoidc/<slug>, and a cookie secret generated by thevault-credential-setupJob (oauth2-cookie-secrets/<slug>, see the mediavault-credential-setup.toml). Themedia-secretsVault policy grants read onoidc/radarr,oidc/sonarrandoidc/bazarr.
Image tags
Section titled “Image tags”Deployments use :latest with a # pin via Renovate/kustomize images: marker. Pin
exact tags (a kustomize images: block, or Renovate) before treating this as
reproducible.
Wiring
Section titled “Wiring”Most of this is done declaratively by the wiring Job — pinned API keys, NZBGet download clients, root folders, and Prowlarr→app links. See Declarative *arr wiring. The manual order below is the fallback / the bits not automated (Bazarr, quality profiles).
Wiring order (in the UIs, after first sync)
Section titled “Wiring order (in the UIs, after first sync)”- Prowlarr → add Usenet indexers; Settings → Apps → add Sonarr
(
http://sonarr:8989) and Radarr (http://radarr:7878) by API key. - Sonarr & Radarr → Download Clients → NZBGet, host
nzbget.media.svcport6789, categorytv/movies; enable Use Hardlinks instead of Copy; no remote path mapping (both see/data). - Root folders: Sonarr
/data/media/tv, Radarr/data/media/movies. - Bazarr → add Sonarr + Radarr (API keys), set languages/providers.
- Jellyfin → libraries at
/data/media/tvand/data/media/movies. - Seerr → sign in with Jellyfin (
http://jellyfin:8096) to import users + libraries, then add Sonarr/Radarr (root folders + a default quality profile).
Verify
Section titled “Verify”# Manifests render and /config is all ceph-block, /data is the RWX PVC:kubectl kustomize tenants/k8s.services.homelab.muehlena.de/k8s/manifests/applications/media
# After commit, Flux reconciles:flux get kustomizations | grep applications-media
# Mount the export by hand from the cluster:kubectl -n media run nfs-test --rm -it --image=busybox -- \ mount -t nfs4 10.50.0.31:/data /mnt
# Hardlink proof — import in Sonarr, then the inode matches on both sides:# ls -i /data/usenet/complete/tv/<file> == ls -i /data/media/tv/<file>