Skip to content

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.

  • media-data — a static RWX PersistentVolume using the in-tree nfs volume 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 with subPath — Kubernetes treats a subPath as a different filesystem and Sonarr can’t hardlink across it.
  • <app>-config — per-app ceph-block RWO PVCs for SQLite/state (and Jellyfin’s transcode temp). SQLite over NFS corrupts, so config never touches NFS.

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.

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/config first.

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 to 127.0.0.1:<app-port>. The *arr apps run in AuthenticationMethod=External + DisabledForLocalAddresses and 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 the homelab__media group via a policy binding. The module writes client_id/ client_secret to Vault at secrets/oidc/<slug>.
  • The sidecar reads three secrets via vault-secrets-operator (shared media-secrets Vault auth): client_id + client_secret from oidc/<slug>, and a cookie secret generated by the vault-credential-setup Job (oauth2-cookie-secrets/<slug>, see the media vault-credential-setup.toml). The media-secrets Vault policy grants read on oidc/radarr, oidc/sonarr and oidc/bazarr.

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.

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)”
  1. Prowlarr → add Usenet indexers; Settings → Apps → add Sonarr (http://sonarr:8989) and Radarr (http://radarr:7878) by API key.
  2. Sonarr & Radarr → Download Clients → NZBGet, host nzbget.media.svc port 6789, category tv / movies; enable Use Hardlinks instead of Copy; no remote path mapping (both see /data).
  3. Root folders: Sonarr /data/media/tv, Radarr /data/media/movies.
  4. Bazarr → add Sonarr + Radarr (API keys), set languages/providers.
  5. Jellyfin → libraries at /data/media/tv and /data/media/movies.
  6. Seerr → sign in with Jellyfin (http://jellyfin:8096) to import users + libraries, then add Sonarr/Radarr (root folders + a default quality profile).
Terminal window
# 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>