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.
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
| Component | Purpose |
|---|---|
| frpc Deployment | Tunnel client - dials out from the cluster to frps |
| frpc NetworkPolicy | Egress locked to DNS, the public internet, and Plex only |
| ADVERTISE_IP Secret | Publishes 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> 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
fatedier, "frp - A fast reverse proxy," github.com. Accessed: Jul. 3, 2026. [Online]. Available: https://github.com/fatedier/frp ↩
Plex Inc., "plexinc/pms-docker - Plex Media Server in Docker," github.com. Accessed: Jul. 3, 2026. [Online]. Available: https://github.com/plexinc/pms-docker ↩
Helm, "Using Helm: The Format and Limitations of --set," helm.sh. Accessed: Jul. 3, 2026. [Online]. Available: https://helm.sh/docs/intro/using_helm/ ↩