𝔩𝔢𝔩𝕠𝔭𝔢𝔷
Theme

Homelab

Plex Remote Access via frp

Remote Plex Access Without Port Forwarding: Publishing Your Home Server Through a Self-Hosted frp Tunnel

Overview

This article publishes your home Plex server to the internet through frp1, a reverse proxy that tunnels traffic from a public host back to your cluster over an outbound-only connection. Your router stays closed - no port forwarding, no inbound rules. Clients that can't run a VPN (Apple TV has no Tailscale client on tvOS) stream remotely like the server was public, with a real Let's Encrypt certificate on the tunnel hostname.

Tip

Having trouble? See v1.13.0 for what your setup should look like after completing this article.

Before You Begin

Prerequisites

  • Plex Hardening completed (Plex running behind NetworkPolicies with a MetalLB IP)
  • An frp server (frps) on a public host, fronted by an ingress that terminates HTTPS for your Plex hostname with a valid certificate
  • A DNS record for the Plex hostname (e.g., plex.<tunnel-domain>) pointing at that public host

What We're Setting Up

ComponentPurpose
frpc DeploymentTunnel client - dials out from the cluster to frps
frpc NetworkPolicyEgress locked to DNS, the public internet, and Plex only
ADVERTISE_IP SecretPublishes the tunnel URL to plex.tv for client discovery

Why This Approach

With the V3 hardening in place, nothing inbound reaches the home network. Tailscale covers devices that can run it, but tvOS can't - and handing a VPN login to every family device defeats the point. frp inverts the connection: the client (frpc) runs inside the cluster and dials out to the server (frps) on a public host, which forwards public traffic back down the tunnel.

The tunnel carries plain TCP, but TLS termination happens at the public host's ingress - not at Plex. This matters: if you pass raw TCP straight through to Plex's port 32400, remote clients see Plex's own *.plex.direct certificate, which doesn't match the tunnel hostname, and Apple TV refuses the connection. Terminating HTTPS at the ingress with a certificate issued for the tunnel hostname fixes it - clients validate against the hostname they actually connected to, and the backend hop to Plex is invisible to them.

The result is structurally the same as Cloudflare Tunnel - outbound-only from the origin, hostname routing and TLS at the edge - except you own the edge.

How the Tunnel Works

Plex client (off-LAN)
  └── HTTPS :443 → public ingress (TLS terminates, routes by hostname)
      └── frps :32400 → tunnel → frpc (home cluster)
          └── Plex MetalLB IP :32400 (HTTP)

frpc control channel (outbound from home)
  └── TCP :7000 → frps (TLS + token auth)

Plex publishes its access URLs to plex.tv via ADVERTISE_IP2. Clients fetch the server's URL list from plex.tv and try each in order - LAN clients hit the MetalLB IP directly, off-LAN clients fall back to the tunnel URL.

What's Not In Scope

  • The cloud side (frps Deployment, ingress-nginx, cert-manager) - that lives in a separate infrastructure repository. This article covers the home-cluster half.
  • Raw TCP/UDP game servers - Minecraft and Factorio keep their playit.gg tunnels.

Create frp Client Manifests

Namespace

k8s/apps/frp-tunnel/namespace.yaml:

---
apiVersion: v1
kind: Namespace
metadata:
  name: frp-tunnel

Secret: frps Address and Token

The frps address and auth token stay out of git plaintext - both live in a sops-encrypted Secret and reach frpc as environment variables.

k8s/apps/frp-tunnel/secret.sops.yaml (before encryption):

---
apiVersion: v1
kind: Secret
metadata:
  name: frp-token
  namespace: frp-tunnel
stringData:
  server-addr: <frps-host>
  token: <frps-auth-token>

Encrypt:

sops --encrypt --in-place k8s/apps/frp-tunnel/secret.sops.yaml

ConfigMap: frpc.toml

frpc supports {{ .Envs.VAR }} templating, so the config file itself contains no secrets. The single proxy entry forwards frps's public port 32400 to Plex's MetalLB IP.

k8s/apps/frp-tunnel/configmap.yaml:

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: frpc-config
  namespace: frp-tunnel
data:
  frpc.toml: |
    serverAddr = "{{ .Envs.FRP_SERVER_ADDR }}"
    serverPort = 7000

    auth.method = "token"
    auth.token = "{{ .Envs.FRP_TOKEN }}"

    # TLS on the control channel — frps has transport.tls.force = true.
    transport.tls.enable = true

    log.to = "console"
    log.level = "info"

    # Plex passthrough — forwards public 32400 (via frps) to Plex MetalLB IP.
    [[proxies]]
    name = "plex"
    type = "tcp"
    localIP = "192.168.10.40"
    localPort = 32400
    remotePort = 32400

Deployment

The image is pinned by digest, and the container runs with the same hardened defaults as the rest of the cluster - non-root, read-only root filesystem, no capabilities.

k8s/apps/frp-tunnel/deployment.yaml:

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frpc
  namespace: frp-tunnel
  labels:
    app.kubernetes.io/name: frp-tunnel
    app.kubernetes.io/component: frpc
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: frp-tunnel
      app.kubernetes.io/component: frpc
  template:
    metadata:
      labels:
        app.kubernetes.io/name: frp-tunnel
        app.kubernetes.io/component: frpc
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65534
        runAsGroup: 65534
        fsGroup: 65534
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: frpc
          image: fatedier/frpc:v0.69.0@sha256:2e7653fc08b28013f598ff20b024a7d8cce0f5ea810cab9e2e90ae7ce4687a6e
          imagePullPolicy: IfNotPresent
          args:
            - "-c"
            - "/etc/frp/frpc.toml"
          env:
            - name: FRP_TOKEN
              valueFrom:
                secretKeyRef:
                  name: frp-token
                  key: token
            - name: FRP_SERVER_ADDR
              valueFrom:
                secretKeyRef:
                  name: frp-token
                  key: server-addr
          volumeMounts:
            - name: config
              mountPath: /etc/frp
              readOnly: true
          resources:
            requests:
              memory: "32Mi"
              cpu: "25m"
            limits:
              memory: "128Mi"
              cpu: "100m"
          securityContext:
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
            capabilities:
              drop:
                - ALL
      volumes:
        - name: config
          configMap:
            name: frpc-config
            items:
              - key: frpc.toml
                path: frpc.toml

NetworkPolicy: Restrict frpc Egress

frpc needs exactly three things: DNS, the public internet for the control channel, and Plex. The V3 pattern applies - allow 0.0.0.0/0 but carve out all RFC1918 space, then add back only the Plex MetalLB IP.

k8s/apps/frp-tunnel/networkpolicy.yaml:

---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: frpc
  namespace: frp-tunnel
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    # DNS
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
      ports:
        - protocol: UDP
          port: 53
    # Public internet for the frps control channel — V3 pattern: block RFC1918.
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 10.0.0.0/8
              - 172.16.0.0/12
              - 192.168.0.0/16
    # Specific RFC1918 exception for Plex MetalLB IP (passthrough target).
    - to:
        - ipBlock:
            cidr: 192.168.10.40/32
      ports:
        - protocol: TCP
          port: 32400

Kustomization

k8s/apps/frp-tunnel/kustomization.yaml:

---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - namespace.yaml
  - configmap.yaml
  - secret.sops.yaml
  - deployment.yaml
  - networkpolicy.yaml

k8s/apps/kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - plex
  - factorio
  - minecraft
  - frp-tunnel # ADD

Publish the Tunnel URL to Plex

ADVERTISE_IP is a comma-separated list of URLs that Plex publishes to plex.tv. Order matters: the LAN URL goes first so local clients connect directly, the tunnel URL second as the off-LAN fallback. The HTTPS URL carries no port - the tunnel path serves on standard 443.

The value moves from the plex-vars ConfigMap into the existing sops-encrypted Secret, keeping the public tunnel hostname out of the repository in plaintext.

Secret: advertise-ip

k8s/apps/plex/secret.sops.yaml (before encryption):

stringData:
  claim: <plex-claim-token>
  advertise-ip: http://192.168.10.40:32400\,https://plex.<tunnel-domain>
Important

The \, is required. Flux's helm-controller passes valuesFrom values with a targetPath through Helm's strvals parser (the same parser behind helm install --set), which splits on top-level commas3. Without the escape, Flux fails to resolve the Secret reference, reporting that the text after the comma "has no value" - it tried to parse the second URL as another key-value pair. YAML quoting does not help: quotes only affect how Kubernetes reads the manifest, and strvals sees the same literal string either way.

HelmRelease: Point ADVERTISE_IP at the Secret

k8s/apps/plex/helmrelease.yaml (valuesFrom section):

# ... existing HelmRepository + HelmRelease header ...
  valuesFrom:
    - kind: Secret
      name: plex-claim
      valuesKey: claim
      targetPath: extraEnv.PLEX_CLAIM
    - kind: Secret                                      # was: ConfigMap plex-vars
      name: plex-claim
      valuesKey: advertise-ip
      targetPath: extraEnv.ADVERTISE_IP
    - kind: ConfigMap
      name: plex-vars
      valuesKey: PLEX_IP
      targetPath: service.annotations.metallb\.universe\.tf/loadBalancerIPs

ConfigMap: Remove ADVERTISE_IP

k8s/apps/plex/configmap.yaml:

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: plex-vars
  namespace: plex
data:
  PLEX_IP: "192.168.10.40"

Deploy the Tunnel

Git: Commit frp Client and Plex Changes

cd ~/homelab
git add k8s/apps/frp-tunnel/ k8s/apps/plex/ k8s/apps/kustomization.yaml
git commit -m "feat(frp-tunnel): add frpc client and publish tunnel URL via ADVERTISE_IP"
git push

Flux: Reconcile in Order

Order matters when the change lives in a Secret. Reconciling only the HelmRelease re-renders the chart against the old Secret value - the Kustomization step is what applies the new Secret to the cluster first.

flux reconcile source git flux-system
flux reconcile kustomization flux-system -n flux-system
flux reconcile helmrelease plex -n plex

Configure Plex Network Settings

In Plex Server → Settings → Network, set Secure connections to Preferred (not "Required"). TLS terminates at the public ingress, so the backend hop from the tunnel to Plex arrives as plain HTTP - "Required" would reject it. Remote clients still get full TLS: they validate the Let's Encrypt certificate on the tunnel hostname.

Verify Remote Access

Verify: ADVERTISE_IP in Pod

kubectl -n plex get statefulset plex-plex-media-server -o yaml | grep -A1 ADVERTISE_IP

Expected:

- name: ADVERTISE_IP
  value: http://192.168.10.40:32400,https://plex.<tunnel-domain>

The comma is literal and the backslash is gone - strvals consumed the escape. If the value differs from what you committed, the problem is the escape or the reconcile order, not plex.tv propagation.

Verify: Tunnel Endpoint

From off-LAN (a phone hotspot works):

curl -v https://plex.<tunnel-domain>/identity

Expected: a certificate with subject: CN=plex.<tunnel-domain> issued by Let's Encrypt, an HTTP/2 200, and a <MediaContainer ...> XML body carrying your server's machineIdentifier.

Verify: Apple TV Playback

On an Apple TV signed into the same plex.tv account, the server appears with no client-side configuration - plex.tv hands it the URL list, and off-LAN it connects through the tunnel. Play something and let it run: sustained playback with no buffering is the practical signal that you're streaming through the tunnel directly rather than through Plex Relay and its bandwidth cap.

Next Steps

With the tunnel live, any HTTP-shaped service on the cluster can be published the same way: add a proxy entry to frpc.toml, a matching NetworkPolicy exception, and an ingress host plus DNS record on the public side.

Resources

Footnotes

  1. fatedier, "frp - A fast reverse proxy," github.com. Accessed: Jul. 3, 2026. [Online]. Available: https://github.com/fatedier/frp

  2. Plex Inc., "plexinc/pms-docker - Plex Media Server in Docker," github.com. Accessed: Jul. 3, 2026. [Online]. Available: https://github.com/plexinc/pms-docker

  3. Helm, "Using Helm: The Format and Limitations of --set," helm.sh. Accessed: Jul. 3, 2026. [Online]. Available: https://helm.sh/docs/intro/using_helm/