𝔩𝔢𝔩𝕠𝔭𝔢𝔷
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