𝔩𝔢𝔩𝕠𝔭𝔢𝔷
Theme

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

What We're Hardening

ComponentBeforeAfter
StorageUnencrypted longhornEncrypted longhorn-encrypted
NetworkPolicyNoneEgress limited
Media mountRead-writeRead-only
Helm chart0.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, preferences
  • plex-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 SizeEstimated 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

  1. Longhorn, "Volume Encryption," longhorn.io. Accessed: Mar. 1, 2026. [Online]. Available: https://longhorn.io/docs/1.9.2/advanced-resources/security/volume-encryption/ ↩

  2. J. Corbet, "A look at rsync performance," lwn.net. Accessed: Mar. 1, 2026. [Online]. Available: https://lwn.net/Articles/400489/ ↩

  3. 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 ↩

  4. D. Gledhill, "k-rsync - kube-native parallel rsync," github.io. Accessed: Mar. 1, 2026. [Online]. Available: https://doughgle.github.io/k-rsync/ ↩

Previous
Factorio Hardening