Add media stack: Plex, Radarr, Sonarr, Prowlarr, qBittorrent, Bazarr, Overseerr

- Namespace: media (created via Terraform)
- Storage: hostPath PV at /media on Legion (movies, tv/shows, tv/anime, downloads)
- VPN: gluetun sidecar with ProtonVPN WireGuard (US-TX#253), all torrent traffic tunneled
- Radarr: movie automation with /media/movies root
- Sonarr: TV/anime automation with /media/tv/{shows,anime} roots
- Prowlarr: indexer aggregator (YTS, 1337x, Nyaa for anime)
- qBittorrent: torrent client, network namespaced behind gluetun VPN
- Bazarr: automatic subtitle downloads (connects to Radarr + Sonarr)
- Overseerr: family request portal at watch.nook.family
- Plex: media server at plex.nook.family
This commit is contained in:
Will Anderson
2026-04-10 22:50:13 -05:00
parent 8c40075b81
commit ba99111753
12 changed files with 845 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: media
namespace: argocd
spec:
project: default
source:
repoURL: https://git.neuralplatform.ai/will/infrastructure.git
targetRevision: main
path: servers/legion/k8s/media
destination:
server: https://kubernetes.default.svc
namespace: media
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+77
View File
@@ -0,0 +1,77 @@
# Bazarr — automatic subtitle downloader
# Connects to Radarr + Sonarr, downloads subtitles from OpenSubtitles/Subscene/etc.
# After deploy, configure at bazarr:6767:
# 1. Settings > Radarr: URL=http://radarr:7878, API key from Radarr Settings > General
# 2. Settings > Sonarr: URL=http://sonarr:8989, API key from Sonarr Settings > General
# 3. Settings > Providers: add OpenSubtitles.com (free account) for best coverage
# 4. Settings > Languages: add English (and any other desired languages)
apiVersion: apps/v1
kind: Deployment
metadata:
name: bazarr
namespace: media
labels:
app: bazarr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: bazarr
template:
metadata:
labels:
app: bazarr
spec:
containers:
- name: bazarr
image: lscr.io/linuxserver/bazarr:latest
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: "America/Chicago"
ports:
- containerPort: 6767
volumeMounts:
- name: config
mountPath: /config
- name: media
mountPath: /media
resources:
requests:
memory: 128Mi
cpu: 50m
limits:
memory: 512Mi
cpu: 200m
livenessProbe:
httpGet:
path: /
port: 6767
initialDelaySeconds: 30
periodSeconds: 30
volumes:
- name: config
persistentVolumeClaim:
claimName: bazarr-config
- name: media
persistentVolumeClaim:
claimName: media-data
---
apiVersion: v1
kind: Service
metadata:
name: bazarr
namespace: media
spec:
selector:
app: bazarr
ports:
- name: http
port: 6767
targetPort: 6767
type: ClusterIP
@@ -0,0 +1,25 @@
# media-secrets — ProtonVPN WireGuard key + Plex claim token
# PLEX_CLAIM: run `vault kv patch secret/plex claim_token=<token>` right before first deploy
# Get token from: https://www.plex.tv/claim (expires in 4 minutes)
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: media-secrets
namespace: media
spec:
refreshInterval: 1h
secretStoreRef:
name: vault
kind: ClusterSecretStore
target:
name: media-secrets
creationPolicy: Owner
data:
- secretKey: PROTONVPN_PRIVATE_KEY
remoteRef:
key: secret/data/protonvpn
property: private_key
- secretKey: PLEX_CLAIM
remoteRef:
key: secret/data/plex
property: claim_token
@@ -0,0 +1,115 @@
# gluetun + qBittorrent — all torrent traffic routed through ProtonVPN WireGuard
# gluetun runs as the VPN container; qBittorrent shares its network namespace (same pod)
apiVersion: apps/v1
kind: Deployment
metadata:
name: qbittorrent
namespace: media
labels:
app: qbittorrent
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: qbittorrent
template:
metadata:
labels:
app: qbittorrent
spec:
initContainers:
# Ensure /dev/net/tun exists on the node
- name: tun-setup
image: busybox:latest
command: ["sh", "-c", "mkdir -p /dev/net && [ -c /dev/net/tun ] || mknod /dev/net/tun c 10 200 && chmod 666 /dev/net/tun"]
securityContext:
privileged: true
containers:
# VPN container — must come first so network is up before qBittorrent starts
- name: gluetun
image: ghcr.io/qdm12/gluetun:latest
securityContext:
capabilities:
add: ["NET_ADMIN"]
env:
- name: VPN_SERVICE_PROVIDER
value: "custom"
- name: VPN_TYPE
value: "wireguard"
- name: WIREGUARD_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: media-secrets
key: PROTONVPN_PRIVATE_KEY
- name: WIREGUARD_PUBLIC_KEY
value: "mngiSxBpH7GU24nnWdBEcnhDnCPn2jq5+ZP3zwPwISA="
- name: WIREGUARD_ADDRESSES
value: "10.2.0.2/32"
- name: VPN_ENDPOINT_IP
value: "95.173.217.29"
- name: VPN_ENDPOINT_PORT
value: "51820"
# Allow cluster-internal traffic to bypass VPN (for Radarr/Sonarr → qBittorrent API)
- name: FIREWALL_OUTBOUND_SUBNETS
value: "10.42.0.0/16,10.43.0.0/16"
- name: UPDATER_PERIOD
value: "0"
ports:
- containerPort: 8888 # gluetun HTTP proxy (unused but required)
resources:
requests:
memory: 64Mi
cpu: 50m
limits:
memory: 128Mi
cpu: 200m
# qBittorrent — shares gluetun's network namespace, all traffic through VPN
- name: qbittorrent
image: lscr.io/linuxserver/qbittorrent:latest
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: "America/Chicago"
- name: WEBUI_PORT
value: "8080"
ports:
- containerPort: 8080
volumeMounts:
- name: config
mountPath: /config
- name: media
mountPath: /media
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: 500m
volumes:
- name: config
persistentVolumeClaim:
claimName: qbittorrent-config
- name: media
persistentVolumeClaim:
claimName: media-data
---
apiVersion: v1
kind: Service
metadata:
name: qbittorrent
namespace: media
spec:
selector:
app: qbittorrent
ports:
- name: webui
port: 8080
targetPort: 8080
type: ClusterIP
+58
View File
@@ -0,0 +1,58 @@
# watch.nook.family → Overseerr (family request portal)
# NOTE: After deploying, add this route in Cloudflare Zero Trust:
# Zero Trust > Networks > Tunnels > neural-platform > Public Hostname
# Hostname: watch.nook.family | Service: http://traefik.kube-system:80
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: overseerr
namespace: media
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- watch.nook.family
secretName: overseerr-tls
rules:
- host: watch.nook.family
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: overseerr
port:
number: 5055
---
# plex.nook.family → Plex
# NOTE: Also add in Cloudflare Zero Trust:
# Hostname: plex.nook.family | Service: http://traefik.kube-system:80
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: plex
namespace: media
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- plex.nook.family
secretName: plex-tls
rules:
- host: plex.nook.family
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: plex
port:
number: 32400
+75
View File
@@ -0,0 +1,75 @@
# Overseerr — family request portal (watch.nook.family)
# Family members browse and request content here
# After deploy, complete setup wizard at watch.nook.family:
# 1. Sign in with Plex account
# 2. Connect to Plex server
# 3. Add Radarr (http://radarr:7878) and Sonarr (http://sonarr:8989)
# 4. Create managed user accounts for each family member
# 5. Set request permissions per user (kids require approval)
apiVersion: apps/v1
kind: Deployment
metadata:
name: overseerr
namespace: media
labels:
app: overseerr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: overseerr
template:
metadata:
labels:
app: overseerr
spec:
containers:
- name: overseerr
image: sctx/overseerr:latest
env:
- name: TZ
value: "America/Chicago"
ports:
- containerPort: 5055
volumeMounts:
- name: config
mountPath: /app/config
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: 500m
livenessProbe:
httpGet:
path: /api/v1/status
port: 5055
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/v1/status
port: 5055
initialDelaySeconds: 15
periodSeconds: 10
volumes:
- name: config
persistentVolumeClaim:
claimName: overseerr-config
---
apiVersion: v1
kind: Service
metadata:
name: overseerr
namespace: media
spec:
selector:
app: overseerr
ports:
- name: http
port: 5055
targetPort: 5055
type: ClusterIP
+103
View File
@@ -0,0 +1,103 @@
# Plex Media Server
# BEFORE FIRST DEPLOY: add claim token to Vault
# 1. Go to https://www.plex.tv/claim (token expires in 4 minutes)
# 2. vault kv put secret/plex claim_token=<token>
# 3. Then push and let Argo CD deploy
# After first startup the token is stored in config and can be left as-is
#
# Post-deploy setup:
# 1. Access via plex.nook.family or port-forward :32400
# 2. Add libraries:
# - Movies → /media/movies
# - TV Shows → /media/tv/shows
# - Anime (TV Shows) → /media/tv/anime
# 3. Settings > Manage > Users & Sharing → create Plex Home managed users
# 4. Set content rating limits per child profile (G/PG/PG-13)
# 5. Enable PIN on adult profiles
apiVersion: apps/v1
kind: Deployment
metadata:
name: plex
namespace: media
labels:
app: plex
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: plex
template:
metadata:
labels:
app: plex
spec:
containers:
- name: plex
image: lscr.io/linuxserver/plex:latest
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: "America/Chicago"
- name: VERSION
value: "docker"
- name: PLEX_CLAIM
valueFrom:
secretKeyRef:
name: media-secrets
key: PLEX_CLAIM
optional: true # safe after first boot
- name: ADVERTISE_IP
value: "https://plex.nook.family:443"
ports:
- containerPort: 32400
name: plex
volumeMounts:
- name: config
mountPath: /config
- name: media
mountPath: /media
resources:
requests:
memory: 1Gi
cpu: 500m
limits:
memory: 4Gi
cpu: 2000m
livenessProbe:
httpGet:
path: /identity
port: 32400
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /identity
port: 32400
initialDelaySeconds: 30
periodSeconds: 10
volumes:
- name: config
persistentVolumeClaim:
claimName: plex-config
- name: media
persistentVolumeClaim:
claimName: media-data
---
apiVersion: v1
kind: Service
metadata:
name: plex
namespace: media
spec:
selector:
app: plex
ports:
- name: plex
port: 32400
targetPort: 32400
type: ClusterIP
+74
View File
@@ -0,0 +1,74 @@
# Prowlarr — indexer aggregator (connects to torrent trackers, feeds Radarr + Sonarr)
# After deploy, add indexers at prowlarr:9696:
# Recommended public indexers for movies/TV/anime:
# - YTS (movies — excellent quality, very reliable)
# - 1337x (general)
# - TorrentGalaxy (general)
# - Nyaa.si (anime — essential for anime/manga)
# - AnimeTosho (anime with good subtitle/dub tagging)
# Then sync to Radarr + Sonarr via Settings > Apps
apiVersion: apps/v1
kind: Deployment
metadata:
name: prowlarr
namespace: media
labels:
app: prowlarr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: prowlarr
template:
metadata:
labels:
app: prowlarr
spec:
containers:
- name: prowlarr
image: lscr.io/linuxserver/prowlarr:latest
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: "America/Chicago"
ports:
- containerPort: 9696
volumeMounts:
- name: config
mountPath: /config
resources:
requests:
memory: 128Mi
cpu: 50m
limits:
memory: 512Mi
cpu: 300m
livenessProbe:
httpGet:
path: /ping
port: 9696
initialDelaySeconds: 30
periodSeconds: 30
volumes:
- name: config
persistentVolumeClaim:
claimName: prowlarr-config
---
apiVersion: v1
kind: Service
metadata:
name: prowlarr
namespace: media
spec:
selector:
app: prowlarr
ports:
- name: http
port: 9696
targetPort: 9696
type: ClusterIP
+124
View File
@@ -0,0 +1,124 @@
# Shared media PV — hostPath /media on Legion (single-node cluster)
apiVersion: v1
kind: PersistentVolume
metadata:
name: media-data
spec:
capacity:
storage: 500Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
hostPath:
path: /media
type: Directory
claimRef:
namespace: media
name: media-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: media-data
namespace: media
spec:
storageClassName: ""
volumeName: media-data
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 500Gi
---
# Per-service config PVCs (small, local-path provisioner)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: plex-config
namespace: media
spec:
storageClassName: local-path
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: radarr-config
namespace: media
spec:
storageClassName: local-path
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sonarr-config
namespace: media
spec:
storageClassName: local-path
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: prowlarr-config
namespace: media
spec:
storageClassName: local-path
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: qbittorrent-config
namespace: media
spec:
storageClassName: local-path
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: bazarr-config
namespace: media
spec:
storageClassName: local-path
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: overseerr-config
namespace: media
spec:
storageClassName: local-path
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
+80
View File
@@ -0,0 +1,80 @@
# Radarr — movie automation (monitors new releases, grabs torrents via Prowlarr/qBittorrent)
# After deploy, configure at http://radarr.media.svc:7878 or via kubectl port-forward
# Root folder: /media/movies | Download client: qbittorrent:8080
# Quality profile: set MPAA filter to allow G/PG/PG-13/R/NR (unrated for documentaries)
apiVersion: apps/v1
kind: Deployment
metadata:
name: radarr
namespace: media
labels:
app: radarr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: radarr
template:
metadata:
labels:
app: radarr
spec:
containers:
- name: radarr
image: lscr.io/linuxserver/radarr:latest
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: "America/Chicago"
ports:
- containerPort: 7878
volumeMounts:
- name: config
mountPath: /config
- name: media
mountPath: /media
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: 500m
livenessProbe:
httpGet:
path: /ping
port: 7878
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /ping
port: 7878
initialDelaySeconds: 15
periodSeconds: 10
volumes:
- name: config
persistentVolumeClaim:
claimName: radarr-config
- name: media
persistentVolumeClaim:
claimName: media-data
---
apiVersion: v1
kind: Service
metadata:
name: radarr
namespace: media
spec:
selector:
app: radarr
ports:
- name: http
port: 7878
targetPort: 7878
type: ClusterIP
+81
View File
@@ -0,0 +1,81 @@
# Sonarr — TV show & anime automation
# After deploy, configure at sonarr:8989
# Root folders: /media/tv/shows and /media/tv/anime
# For anime: add custom format preferring "Dual Audio" / "English Dub" releases
# Download client: qbittorrent:8080
apiVersion: apps/v1
kind: Deployment
metadata:
name: sonarr
namespace: media
labels:
app: sonarr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: sonarr
template:
metadata:
labels:
app: sonarr
spec:
containers:
- name: sonarr
image: lscr.io/linuxserver/sonarr:latest
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: "America/Chicago"
ports:
- containerPort: 8989
volumeMounts:
- name: config
mountPath: /config
- name: media
mountPath: /media
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: 500m
livenessProbe:
httpGet:
path: /ping
port: 8989
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /ping
port: 8989
initialDelaySeconds: 15
periodSeconds: 10
volumes:
- name: config
persistentVolumeClaim:
claimName: sonarr-config
- name: media
persistentVolumeClaim:
claimName: media-data
---
apiVersion: v1
kind: Service
metadata:
name: sonarr
namespace: media
spec:
selector:
app: sonarr
ports:
- name: http
port: 8989
targetPort: 8989
type: ClusterIP
+13
View File
@@ -0,0 +1,13 @@
# Media stack — Plex, Radarr, Sonarr, Prowlarr, qBittorrent, Bazarr, Overseerr
# Namespace only — all resources managed by Argo CD
# See: apps/media.yaml, k8s/media/
resource "kubernetes_namespace" "media" {
metadata {
name = "media"
labels = {
managed-by = "terraform"
tier = "apps"
}
}
}