Giving Plex on Kubernetes a Real LAN IP with br0, Multus, and GitOps
I moved Plex from Docker Compose to Kubernetes for the same reason I moved a lot of my home-lab services: I wanted the running system to match the system I had in Git. Plex already worked in Compose. The migration was not about escaping a broken setup. It was about making Plex part of the same GitOps-managed platform as the rest of the lab without taking away the LAN identity that Plex clients already knew.
That second part was the constraint that shaped the whole design. Plex is not
only a web app. It is a media server that local clients discover, remember, and
connect to directly. If I had hidden it behind only a normal Kubernetes
Service or HTTP ingress, Kubernetes would have looked tidy, but the user
experience on the LAN could have become weird.
So I gave the Plex pod a real LAN address. Cilium still owns normal Kubernetes
pod networking. Multus adds a second interface to the Plex pod. The bridge CNI
attaches that interface to br0. Plex advertises the address on that interface,
and ingress can still route to it for browser access.
The high-level shape is:
Kubernetes pod network
|
Cilium interface
|
Plex pod
|
lan0 from Multus
|
host bridge br0
|
home LAN
For the example in this repo, the pod gets 192.168.1.50/24 on lan0, and
the shared internal ingress VIP is a separate address. That split is important:
one address belongs to Plex itself, and the other belongs to the ingress path.
Why I Did Not Use Only a Service
Most Kubernetes workloads are happy behind a ClusterIP, Ingress, or
LoadBalancer Service. Plex is awkward because it behaves like both a web
application and a LAN appliance. Local clients expect to find it, connect to a
stable address, and sometimes use traffic patterns that do not fit cleanly into
an HTTP-only route.
I considered the usual Kubernetes options:
hostNetwork: true, which would make the pod share the node’s network
namespace.- A Cilium or MetalLB
LoadBalancerVIP, which would advertise a Service
address on the LAN. - A secondary pod interface with Multus, where Plex gets its own LAN address.
The secondary interface was the cleanest fit. Plex keeps an actual LAN IP inside its own pod network namespace, while Cilium continues to own the normal Kubernetes interface and default route.
That distinction matters:
- A Service VIP is a virtual load-balancer address.
- A Multus LAN IP is assigned to the workload itself.
- Plex discovery and client behavior are easier to reason about when Plex
advertises the address that is actually on its own interface. - Ordinary web apps should usually stay behind ClusterIP, Gateway, Ingress, or
a LoadBalancer VIP instead of getting direct LAN presence.
What I Needed Before Starting
Before I touched the live Plex process, I wanted the prerequisites to be boring. This is the part of the migration where I would rather be slow than clever, because a LAN IP conflict or a broken host bridge turns a clean app migration into a network incident.
The checklist was:
- A Linux bridge on the LAN. In this example, the bridge is
br0. - A reserved, unused LAN address for Plex. In this example, that address is
192.168.1.50. - A Kubernetes node selector or scheduling constraint that pins Plex to the node
withbr0, the media paths, and the device mounts. - Cilium configured so Multus can coexist with it.
- Multus installed on every node that might run Plex.
- The bridge CNI plugin installed on the node.
- The static Plex IP excluded from DHCP or reserved in the network source of
truth. - A rollback path for the old Compose instance.
That last point matters because the first migration reused the same Plex config directory. I did not want Kubernetes Plex and Compose Plex writing to the same state. The rollback command was intentionally simple:
docker compose -f /opt/plex/docker-compose.yml up -d
But that command only belongs after the Kubernetes pod has been scaled down and the LAN address has been released.
The Network Model
The deployment uses both Cilium and Multus, but they have different jobs.
Cilium remains the primary CNI. The Plex pod still gets its normal Kubernetes
interface, DNS behavior, service routing, and cluster reachability from Cilium.
Multus is installed as the shim that lets a pod request extra interfaces.
The bridge CNI plugin does the actual attachment to br0.
The Plex-specific NetworkAttachmentDefinition is intentionally small:
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: plex-br0
labels:
app.kubernetes.io/name: plex
spec:
config: |
{
"cniVersion": "0.3.1",
"type": "bridge",
"bridge": "br0",
"ipam": {
"type": "static",
"addresses": [
{
"address": "192.168.1.50/24"
}
]
}
}
There is no gateway in that attachment. I wanted the secondary interface to provide LAN identity, not steal the pod’s default route from Cilium.
The Deployment asks for the attachment with the Multus pod annotation:
template:
metadata:
annotations:
k8s.v1.cni.cncf.io/networks: '[{"name":"plex-br0","interface":"lan0"}]'
Naming the interface lan0 is mostly for sanity. When I validate the pod, I can
look for lan0 instead of guessing whether the extra interface became net1,
eth1, or something else.
The same Deployment pins Plex to the node with the bridge, media paths, and device mounts:
spec:
nodeSelector:
kubernetes.io/hostname: k8s-node-1
That pin is part of the design. This version of the migration reuses local host paths and a node-local LAN bridge, so it is not pretending to be a portable multi-node Plex deployment.
What br0 Has to Provide
The Kubernetes manifests assume the node already has a bridge named br0 that
is connected to the physical LAN. In my lab that bridge already existed because
other host services and containers used it. If I were starting from a plain
Linux server, I would create the bridge first and move the host’s LAN address
onto the bridge instead of leaving it on the physical NIC.
The target shape is:
physical NIC, for example eno1
|
br0
|
host IP, for example 192.168.1.10/24
A minimal Netplan-style example looks like this:
network:
version: 2
ethernets:
eno1:
dhcp4: false
dhcp6: false
bridges:
br0:
interfaces:
- eno1
addresses:
- 192.168.1.10/24
routes:
- to: default
via: 192.168.1.1
nameservers:
addresses:
- 192.168.1.1
parameters:
stp: false
forward-delay: 0
For a DHCP-based server, the bridge can use dhcp4: true. The important point
is that the host’s LAN presence belongs to br0, because the Plex pod’s
secondary interface will be attached there.
I would only apply bridge changes from an attended console or an out-of-band management path. A bad bridge config can disconnect SSH. After changing it, I check the basics before scheduling any LAN-present workload:
ip -br link show br0
ip -br addr show br0
bridge link
ip route
Then I confirm the node can still reach the LAN gateway, DNS, and the Kubernetes API.
Installing the Bridge CNI Plugin
Multus does not attach the interface to br0 by itself. It reads the
NetworkAttachmentDefinition and dispatches the request to the CNI plugin named
there. In this design, that plugin is bridge, so the node needs the bridge CNI
binary installed, normally under /opt/cni/bin/bridge.
Before cutover, I check the selected node directly:
ssh k8s-node-1 'test -x /opt/cni/bin/bridge && ls -l /opt/cni/bin/bridge'
ssh k8s-node-1 'ls -1 /opt/cni/bin | sed -n "1,40p"'
If the binary is missing, I install the standard CNI plugins through the node
bootstrap path. I do not work around a missing bridge plugin by switching Plex
to hostNetwork. That would change the security model and lose the separate
pod-owned LAN identity that this design is trying to preserve.
Making Cilium and Multus Coexist
Because Cilium is still the primary network, it needs to leave room for Multus. The key Cilium Helm value is:
values:
cni:
exclusive: false
That prevents Cilium from taking exclusive ownership of the host CNI config in a way that would break the Multus shim.
In this cluster, Cilium is also pinned to the LAN-facing bridge:
values:
devices: br0
nodePort:
directRoutingDevice: br0
That is a single-cluster assumption. If the nodes in a different cluster do not all expose the LAN through the same bridge name, I would not copy that blindly. I would either standardize the bridge name or use a device selection policy that matches the actual node fleet.
The Multus platform package installs the NetworkAttachmentDefinition CRD,
RBAC, a multus ServiceAccount, and the kube-multus-ds DaemonSet in
kube-system. The DaemonSet writes a shim config that delegates the primary
pod network to Cilium:
{
"cniVersion": "0.3.1",
"name": "multus-cni-network",
"type": "multus-shim",
"logLevel": "verbose",
"logToStderr": true,
"clusterNetwork": "/host/etc/cni/net.d/05-cilium.conflist"
}
Before reconciling the live platform, I render the packages:
kubectl kustomize config/kubernetes/platform/multus
kubectl kustomize config/kubernetes/platform/live
After GitOps applies them, I check that Multus is actually ready:
kubectl -n kube-system get ds kube-multus-ds
kubectl -n kube-system get pods -l app.kubernetes.io/name=multus -o wide
kubectl get crd network-attachment-definitions.k8s.cni.cncf.io
Preserving Plex Behavior
The migration reused the behavior that mattered from the Docker Compose setup. Plex still sees its existing config and media paths, and it still advertises the LAN address clients should use:
env:
- name: ADVERTISE_IP
value: http://192.168.1.50:32400/
The Deployment exposes the known Plex ports in the container spec:
- TCP
32400 - TCP
8324 - TCP
32469 - UDP
1900 - UDP
5353 - UDP
32410through32414
The image tracks the LinuxServer Plex image tag but pins the resolved digest in Git:
image: lscr.io/linuxserver/plex:latest@sha256:b785bdd60e781662f16e0526a6b54c07856739df95ab558a674a3c084dbde423
The pod also mounts the same host data layout the Compose container used:
/opt/plex/configmounted at/config- media libraries under
/srv/media - photo libraries under
/srv/photos /dev/dvb/dev/shmbacked by a memoryemptyDir
That is not a portable, multi-node Plex design. It pins Plex to the node with
the media, devices, and br0 bridge. I accepted that tradeoff because the goal
was a low-risk migration, not a storage redesign.
GitOps Layout
The manifests are split between platform and app packages.
The platform side includes Cilium and Multus:
config/kubernetes/platform/cilium/helmrelease.yamlconfig/kubernetes/platform/multus/multus.yamlconfig/kubernetes/platform/multus/kustomization.yamlconfig/kubernetes/platform/live/kustomization.yaml
The app side includes Plex:
config/kubernetes/apps/plex/network-attachment-definition.yamlconfig/kubernetes/apps/plex/deployment.yamlconfig/kubernetes/apps/plex/service.yamlconfig/kubernetes/apps/plex/endpointslice.yamlconfig/kubernetes/apps/plex/dns-records.yamlconfig/kubernetes/apps/plex/ingress.yamlconfig/kubernetes/apps/plex/ingress-public-cloudflare.yamlconfig/kubernetes/apps/plex/kustomization.yaml
The live app kustomization includes the Plex package, and Flux reconciles the
desired state from Git. That matters during rollout and rollback because the
manifests are not a one-off kubectl apply artifact. They are the source of
truth.
Connecting Ingress to the LAN IP
Once Plex has a LAN address on the pod itself, ingress can target that address through a selectorless Service and a manual EndpointSlice.
The Service is just a stable Kubernetes object for ingress to reference:
apiVersion: v1
kind: Service
metadata:
name: plex-lan
spec:
type: ClusterIP
ports:
- name: https
protocol: TCP
port: 32400
targetPort: 32400
The EndpointSlice points at the pod’s LAN address:
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: plex-lan
labels:
kubernetes.io/service-name: plex-lan
addressType: IPv4
ports:
- name: https
protocol: TCP
port: 32400
endpoints:
- addresses:
- 192.168.1.50
conditions:
ready: true
That gives the ingress controllers a normal Kubernetes backend while Plex remains a LAN-resident endpoint.
In this repo, the internal ingress uses the internal nginx class and proxies to
plex-lan on port 32400. The public Cloudflare Tunnel ingress points to the
same service. I kept public exposure separate from LAN presence: Plex does not
need a direct router port forward just because it has a LAN IP.
DNS
I split DNS the same way I split networking.
The LAN name points directly at the Plex pod’s LAN IP:
spec:
name: plex.lan.example.com
recordType: A
address: 192.168.1.50
The normal internal app name points at the shared internal ingress VIP:
spec:
name: plex.example.com
recordType: A
address: 192.168.1.20
That gives me two intentional paths:
plex.lan.example.comis direct-to-Plex on the LAN.plex.example.comis the ingress path.
Public DNS is owned by the Cloudflare Tunnel ingress controller, not by the internal DNS record resources. During cutover, the public route moved from the old tunnel target to the Kubernetes-managed tunnel target, while the internal records kept the split-horizon LAN behavior.
Cutover
I treated cutover as a single-writer problem. The old Compose container and the new Kubernetes pod must not run at the same time because they would share the same config path and the same LAN address.
The sequence I used is deliberately conservative.
First, render the platform and app packages:
kubectl kustomize config/kubernetes/platform/multus
kubectl kustomize config/kubernetes/platform/live
kubectl kustomize config/kubernetes/apps/plex
kubectl kustomize config/kubernetes/apps/live
Then reconcile platform GitOps and confirm Multus is ready:
flux -n flux-system reconcile source git your-gitops-repo
flux -n flux-system reconcile kustomization platform
kubectl -n kube-system rollout status ds/kube-multus-ds
kubectl get network-attachment-definitions.k8s.cni.cncf.io -A
Then stop the old Plex process:
docker compose -f /opt/plex/docker-compose.yml down
Then reconcile the app:
flux -n flux-system reconcile kustomization apps
kubectl -n plex rollout status deploy/plex
Only after Plex was healthy on the LAN did I treat the browser-facing routes as done.
Validation
The first thing I validate is the rendered intent. The app package should contain the network attachment and the Deployment annotation:
kubectl kustomize config/kubernetes/apps/plex | less
Then I check that the pod has both interfaces:
kubectl -n plex exec deploy/plex -- ip -br addr
Expected shape:
lo UNKNOWN 127.0.0.1/8
eth0 UP <pod-cidr-address>/...
lan0 UP 192.168.1.50/24
Then I check that Plex answers directly on the LAN address:
curl -fsSI http://192.168.1.50:32400/web
The Kubernetes ingress path depends on the selectorless Service and EndpointSlice, so I check those next:
kubectl -n plex get svc plex-lan
kubectl -n plex get endpointslice plex-lan -o yaml
kubectl -n plex get ingress plex plex-public-cloudflare
curl -I https://plex.example.com
During the migration, the public route returned HTTP/2 401 with
x-plex-protocol: 1.0, which proved the Cloudflare path reached Plex. A 401 was
the expected unauthenticated response, not a failure.
DNS should show the split paths:
dig +short plex.lan.example.com
dig +short plex.example.com
Expected internal answers:
192.168.1.50
192.168.1.20
I also check that the pod sees the data and devices it needs:
kubectl -n plex exec deploy/plex -- ls -la /config
kubectl -n plex exec deploy/plex -- ls -la /srv/media/Videos
kubectl -n plex exec deploy/plex -- ls -la /dev/dvb
If GPU transcoding is part of the deployment, I validate the node resource and then confirm hardware transcoding through Plex during real playback:
kubectl describe node k8s-node-1 | rg -n "nvidia.com/gpu|Capacity|Allocatable"
kubectl -n plex exec deploy/plex -- nvidia-smi
Some Plex images do not include nvidia-smi, so I treat that as a convenience
check, not the only proof. The final proof is playback behavior in Plex.
Failure Modes I Watch For
When the pod gets stuck in ContainerCreating, I start with events:
kubectl -n plex describe pod -l app.kubernetes.io/name=plex
The usual causes are straightforward:
- Multus is not installed or is not ready on the node.
- The
NetworkAttachmentDefinitionname is wrong or in the wrong namespace. - The bridge CNI binary is missing from
/opt/cni/bin. - The host bridge
br0does not exist on the selected node.
If the pod starts but lan0 is missing, I check the annotation:
kubectl -n plex get deploy plex -o jsonpath='{.spec.template.metadata.annotations.k8s\.v1\.cni\.cncf\.io/networks}{"\n"}'
Then I check the network attachment and Multus logs:
kubectl -n plex get network-attachment-definition plex-br0 -o yaml
kubectl -n kube-system logs -l app.kubernetes.io/name=multus --tail=200
If the LAN IP conflicts with another device, the symptoms look like ordinary network confusion: ARP flapping, intermittent connectivity, clients disagreeing about reachability, or the router showing the wrong MAC. Before cutover, I want the address reserved and quiet:
ping -c 1 -W 1 192.168.1.50
arp -n 192.168.1.50
If a conflict appears after cutover, I scale the Kubernetes Deployment to zero first. I do not start Compose until the duplicate address is gone.
If the pod cannot reach the internet, I check routes:
kubectl -n plex exec deploy/plex -- ip route
The secondary LAN interface should not become the default route. If it does, the bridge CNI IPAM config probably grew a gateway or route that does not belong there.
If LAN clients cannot discover Plex, I check that Plex is listening and advertising the LAN address:
kubectl -n plex exec deploy/plex -- printenv ADVERTISE_IP
curl -fsSI http://192.168.1.50:32400/web
Then I check whether the client discovery path depends on multicast traffic
that local firewall policy has to allow. The manifest documents UDP 1900,
5353, and 32410-32414, but Kubernetes container ports are metadata.
Actual firewall behavior still depends on the host, CNI, and network policy
stack.
For this migration, the final proof was not only curl. I verified a local
browser, a remote browser path, and a local Plex app connecting directly to the
LAN address.
If direct LAN access works but ingress fails, I look at the selectorless Service, EndpointSlice, and ingress controller logs:
kubectl -n plex get svc plex-lan -o yaml
kubectl -n plex get endpointslice plex-lan -o yaml
In this setup, the internal ingress talks HTTPS to the backend with certificate verification disabled:
nginx.ingress.kubernetes.io/backend-protocol: HTTPS
nginx.ingress.kubernetes.io/proxy-ssl-verify: "off"
If Plex serves plain HTTP in a future revision, that annotation has to change.
Security Notes
Putting a pod directly on the LAN changes the security model. The workload is no longer reachable only through Kubernetes Services and ingress controllers. It is a LAN endpoint.
I treat the Plex pod like a host on the home network:
- Reserve the IP in the network source of truth before assigning it in the
NetworkAttachmentDefinition. - Pin Plex to the node where the bridge, host paths, and devices are expected.
- Do not reuse the same LAN IP in Compose, LXC, DHCP, or another Kubernetes
workload. - Use this pattern only for workloads that need real LAN presence.
- Keep public exposure separate from LAN exposure.
- Be careful with host paths because the pod can read and write mounted media
and config directories. - Be careful with device mounts such as
/dev/dvband GPU access. - Keep account tokens and provider credentials out of Git.
NetworkPolicy may still be useful for the primary Cilium interface, but I do not assume it automatically describes all traffic entering through the bridge-attached LAN interface. I validate actual enforcement behavior before relying on it for LAN-side isolation.
Rollback
Rollback is intentionally simple because the migration kept the old data layout.
First, scale Kubernetes Plex to zero:
kubectl -n plex scale deploy/plex --replicas=0
Then confirm the LAN address is no longer assigned to the pod:
kubectl -n plex get pods -o wide
ping -c 1 -W 1 192.168.1.50
Then start the preserved Compose service:
docker compose -f /opt/plex/docker-compose.yml up -d
Then validate Plex directly:
curl -fsSI http://192.168.1.50:32400/web
The main rule is that Compose Plex and Kubernetes Plex must not both run
against /opt/plex/config and 192.168.1.50 at the same time.
I also avoid deleting evidence during an incident. If the manifests themselves are destabilizing the platform, I would suspend the relevant Flux Kustomization instead of removing the app package from Git while I am still debugging.
What I Would Reuse
I would reuse this pattern for another LAN-appliance workload, but not for every web app.
For another workload, I would change:
- Namespace and labels.
- The
NetworkAttachmentDefinitionname. - The static LAN IP.
- The pod annotation.
- The node selector.
- Any advertised URL or callback URL.
- The Service and EndpointSlice if ingress needs to route to the LAN IP.
- DNS records and firewall policy.
I would not copy the Plex host paths, ports, or GPU settings unless the new workload actually needed them.
The general rule is the same one I used for Plex: keep Cilium as the Kubernetes network, use Multus only for the extra LAN interface, and make the LAN IP assignment explicit in Git.
References
- Multus pod network annotation and
NetworkAttachmentDefinitionquickstart:
https://k8snetworkplumbingwg.github.io/multus-cni/docs/quickstart.html - CNI bridge plugin:
https://www.cni.dev/plugins/current/main/bridge/ - CNI static IPAM plugin:
https://www.cni.dev/plugins/current/ipam/static/ - Cilium Helm reference for
cni.exclusive:
https://docs.cilium.io/en/stable/helm-reference/ - Kubernetes Services and selectorless Services:
https://kubernetes.io/docs/concepts/services-networking/service/