Homelab
Plex Hardening
Hardening Plex: Encrypted Storage, NetworkPolicy, and Security
Overview
This article hardens the Plex server: we migrate to encrypted storage, lock down network access with NetworkPolicy, make media read-only, and pin Helm versions. Unlike game servers, Plex has large volumes (500GB+ media) that can't be backed up locally, so we use a migration job to copy directly between PVCs.
| Tip: | Having trouble? See v1.10.2 for what your setup should look like after completing this article. |
Before You Begin
Prerequisites
- Factorio Hardening completed
What We're Hardening
| Component | Before | After |
|---|---|---|
| Storage | Unencrypted longhorn | Encrypted longhorn-encrypted |
| NetworkPolicy | None | Egress limited |
| Media mount | Read-write | Read-only |
| Helm chart | 0.x (floating) | 0.11.0 pinned |
Why These Controls
Encrypted Storage: Config and media are protected at rest1. Physical disk theft or node compromise no longer exposes plaintext data.
NetworkPolicy: Plex needs internet access for metadata and authentication. We block access to other VLANs (Network, Drive) while allowing required services.
Read-only Media: A compromised Plex container shouldn't be able to delete or corrupt your media library.
Helm Pinning: Ensures reproducible deployments without surprise breaking changes.
Migration Strategy
Plex has two large PVCs that can't be backed up locally:
plex-config(50Gi) - metadata, database, preferencesplex-media(500Gi) - media files
Instead of backup/restore, we run a migration job that copies directly from the old unencrypted PVCs to new encrypted PVCs while Plex is stopped.
This article requires multiple commits with a manual migration step between them. Tags: v1.10.0 (encrypted PVCs), v1.10.1 (switch to encrypted), v1.10.2 (cleanup).
Migration Performance
Expect ~35-45 MB/s throughput when migrating to encrypted volumes. This is normal given:
- rsync is single-threaded - caps at ~37 MB/s even on fast disks2
- LUKS encryption overhead - minimal with modern CPUs but adds latency3
- Longhorn's storage layer - distributed replication adds overhead vs direct disk
Time estimates by data size:
| Data Size | Estimated Time |
|---|---|
| 50GB | ~20-25 minutes |
| 200GB | ~1.5 hours |
| 500GB | ~3-4 hours |
Parallelization tools like k-rsync4 exist but aren't worth the complexity for a one-time migration.
Check Current Helm Version
helm list -n plex Note the chart version (e.g., plex-media-server-0.11.0) - this is what you'll pin.
Deploy Encrypted PVCs
Create new encrypted PVCs and NetworkPolicy. Plex continues running on the old PVCs during this step.
PVCs: Create Encrypted Volumes
k8s/apps/plex/pvc.yaml:
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: plex-config-encrypted
namespace: plex
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn-encrypted
resources:
requests:
storage: 50Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: plex-media-encrypted
namespace: plex
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn-encrypted
resources:
requests:
storage: 500Gi NetworkPolicy: Internet-Only Egress
k8s/apps/plex/networkpolicy.yaml:
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: plex
namespace: plex
spec:
podSelector: {}
policyTypes:
- Egress
egress:
# DNS resolution
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
# Internet (Plex auth, metadata)
# Block private ranges
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16 Kustomization: Add PVC and Policy
k8s/apps/plex/kustomization.yaml:
# ... existing header ...
resources:
- namespace.yaml
- pvc.yaml
- configmap.yaml
- secret.sops.yaml
- helmrelease.yaml
- networkpolicy.yaml # ADD Git: Commit PVC and NetworkPolicy
git add k8s/apps/plex/pvc.yaml k8s/apps/plex/networkpolicy.yaml k8s/apps/plex/kustomization.yaml
git commit -m "feat(plex): add encrypted PVCs and NetworkPolicy"
git push Flux: Sync PVC and Policy
flux reconcile source git flux-system
flux reconcile kustomization sync Verify: PVCs Created
kubectl get pvc -n plex -w Expected: Four PVCs - two old (plex-config, plex-media) and two new encrypted (plex-config-encrypted, plex-media-encrypted). All should show Bound. Press Ctrl+C to exit watch.
Run Migration
Kubectl: Stop Plex
Stop Plex so data isn't changing during migration:
kubectl scale statefulset -n plex plex-plex-media-server --replicas=0
kubectl wait --for=delete pod -n plex -l app.kubernetes.io/name=plex-media-server --timeout=120s Job: Data Migration
Create the migration job file. This file is applied manually and deleted after migration - do not add it to kustomization.yaml.
k8s/apps/plex/migration-job.yaml:
---
apiVersion: batch/v1
kind: Job
metadata:
name: plex-migrate
namespace: plex
spec:
ttlSecondsAfterFinished: 86400
backoffLimit: 0
template:
spec:
containers:
- name: migrate
image: alpine:3.19
command:
- sh
- -c
- |
apk add --no-cache rsync
echo "Migrating config..."
rsync -av --progress /old-config/ /new-config/
echo "Migrating media..."
rsync -av --progress /old-media/ /new-media/
echo "Migration complete"
volumeMounts:
- name: old-config
mountPath: /old-config
readOnly: true
- name: new-config
mountPath: /new-config
- name: old-media
mountPath: /old-media
readOnly: true
- name: new-media
mountPath: /new-media
resources:
requests:
memory: "256Mi"
cpu: "500m"
limits:
memory: "512Mi"
cpu: "2000m"
volumes:
- name: old-config
persistentVolumeClaim:
claimName: plex-config
- name: new-config
persistentVolumeClaim:
claimName: plex-config-encrypted
- name: old-media
persistentVolumeClaim:
claimName: plex-media
- name: new-media
persistentVolumeClaim:
claimName: plex-media-encrypted
restartPolicy: Never Git: Commit Migration Reference
Optionally commit the migration job for reference. It's not in kustomization.yaml so Flux won't apply it.
git add k8s/apps/plex/migration-job.yaml
git commit -m "docs(plex): add migration job for reference (not in kustomization)"
git push Kubectl: Apply Migration Job
kubectl apply -f k8s/apps/plex/migration-job.yaml Check: Migration Progress
Wait for the pod to start:
kubectl get pod -n plex -l job-name=plex-migrate -w Once it shows Running, follow the logs:
kubectl logs -n plex -f job/plex-migrate This may take a while depending on media size (~35-45 MB/s throughput). For 500GB, expect 3-4 hours.
Expected: Log shows rsync progress, ends with "Migration complete".
Verify: Migration Complete
# Check job completed
kubectl get job -n plex plex-migrate
# Check old PVC sizes
kubectl run -n plex check-old --rm -it --restart=Never --image=alpine \
--overrides='{
"spec": {
"containers": [{
"name": "check",
"image": "alpine",
"command": ["sh", "-c", "du -sh /config /media"],
"volumeMounts": [
{"name": "config", "mountPath": "/config"},
{"name": "media", "mountPath": "/media"}
]
}],
"volumes": [
{"name": "config", "persistentVolumeClaim": {"claimName": "plex-config"}},
{"name": "media", "persistentVolumeClaim": {"claimName": "plex-media"}}
]
}
}'
# Check new PVC sizes (should match old)
kubectl run -n plex check-new --rm -it --restart=Never --image=alpine \
--overrides='{
"spec": {
"containers": [{
"name": "check",
"image": "alpine",
"command": ["sh", "-c", "du -sh /config /media"],
"volumeMounts": [
{"name": "config", "mountPath": "/config"},
{"name": "media", "mountPath": "/media"}
]
}],
"volumes": [
{"name": "config", "persistentVolumeClaim": {"claimName": "plex-config-encrypted"}},
{"name": "media", "persistentVolumeClaim": {"claimName": "plex-media-encrypted"}}
]
}
}' Switch to Encrypted PVCs
Update HelmRelease to use the encrypted PVCs with read-only media and pinned version.
HelmRelease: Switch to Encrypted
k8s/apps/plex/helmrelease.yaml:
# ... existing HelmRepository ...
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: plex
namespace: plex
spec:
interval: 30m
chart:
spec:
chart: plex-media-server
version: "0.11.0" # PIN to current version
sourceRef:
kind: HelmRepository
name: plex
# ... existing valuesFrom ...
values:
# ... existing image, extraEnv, service ...
pms:
configExistingClaim: plex-config-encrypted # CHANGE from plex-config
extraVolumes:
- name: media
persistentVolumeClaim:
claimName: plex-media-encrypted # CHANGE from plex-media
- name: dev-dri
hostPath:
path: /dev/dri
type: Directory
extraVolumeMounts:
- name: media
mountPath: /data/media
readOnly: true # ADD: prevent media modification
- name: dev-dri
mountPath: /dev/dri Git: Commit HelmRelease Update
git add k8s/apps/plex/helmrelease.yaml
git commit -m "feat(plex): switch to encrypted PVCs with read-only media"
git push Flux: Sync HelmRelease Update
flux reconcile source git flux-system
flux reconcile kustomization sync Kubectl: Start Plex
The manual scale-down persists until you scale back up (Flux doesn't override imperative changes unless replicas is explicitly set in HelmRelease values).
kubectl scale statefulset -n plex plex-plex-media-server --replicas=1 Plex will start using the encrypted volumes.
Verify Hardening
Verify: Pod Running
kubectl get pods -n plex -w Expected: Pod transitions to Running with all containers ready. Press Ctrl+C to exit watch.
Verify: Encrypted PVCs in Use
kubectl get pod -n plex -l app.kubernetes.io/name=plex-media-server -o jsonpath='{.items[0].spec.volumes[*].persistentVolumeClaim.claimName}' Expected: plex-config-encrypted and plex-media-encrypted
Verify: NetworkPolicy Applied
kubectl get networkpolicy -n plex Expected: plex policy listed.
Verify: Plex Functional
Open Plex in your browser and verify:
- Library loads correctly
- Media plays
- Metadata is intact
Clean Up Migration Resources
Kubectl: Delete Job and Old PVCs
kubectl delete job -n plex plex-migrate
kubectl delete pvc -n plex plex-config plex-media PVCs: Remove Old Definitions
Remove old PVC definitions from k8s/apps/plex/pvc.yaml (keep only the encrypted PVCs):
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: plex-config-encrypted
namespace: plex
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn-encrypted
resources:
requests:
storage: 50Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: plex-media-encrypted
namespace: plex
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn-encrypted
resources:
requests:
storage: 500Gi Git: Commit Cleanup
rm k8s/apps/plex/migration-job.yaml
git add k8s/apps/plex/pvc.yaml
git rm k8s/apps/plex/migration-job.yaml
git commit -m "chore(plex): remove old PVCs and migration job"
git push Next Steps
With all applications hardened, encrypt the Talos system disks to protect etcd and cluster state.
See: SecureBoot & Encryption Prep
For Plex performance tuning (4K playback, GPU transcoding):
Resources
Footnotes
Longhorn, "Volume Encryption," longhorn.io. Accessed: Mar. 1, 2026. [Online]. Available: https://longhorn.io/docs/1.9.2/advanced-resources/security/volume-encryption/ ↩
J. Corbet, "A look at rsync performance," lwn.net. Accessed: Mar. 1, 2026. [Online]. Available: https://lwn.net/Articles/400489/ ↩
Linux Mint Forums, "Performance impact of LUKS encryption," forums.linuxmint.com. Accessed: Mar. 1, 2026. [Online]. Available: https://forums.linuxmint.com/viewtopic.php?t=410375 ↩
D. Gledhill, "k-rsync - kube-native parallel rsync," github.io. Accessed: Mar. 1, 2026. [Online]. Available: https://doughgle.github.io/k-rsync/ ↩