LiveKit can run on Kubernetes without hostNetwork, which lets self-hosted LiveKit scale like any other workload and run in clusters that don’t allow host networking. Its pods stay private behind an ordinary ClusterIP Service, and STUNner, a Kubernetes-native TURN server, provides the single public address that WebRTC media flows through.

LiveKit is open source and distributed by design, which makes it a natural fit for Kubernetes. LiveKit’s Kubernetes guide, though, deploys the server with host networking (hostNetwork: true), where each pod uses its node’s network stack directly. That keeps WebRTC connectivity simple: media reaches the server with no NAT in between. The trade-off is operational. Each pod is tied to its node, the cluster is limited to one LiveKit pod per node, and the RTC ports are open directly on the host. That suits dedicated nodes with public IPs. It suits private or hardened Kubernetes clusters less well. LiveKit’s guide notes that private clusters add NAT layers that suit WebRTC poorly, and that is the problem STUNner’s TURN relay is built to solve.

This is the third post in our series on running WebRTC media servers privately on Kubernetes. The Janus post built the coordination layer that Janus lacks, because a Janus room lives on exactly one instance. The STUNner post covered the TURN entry point that any media server can sit behind. LiveKit keeps room state in Redis, so a client can connect to any pod and be routed to its room, and the one piece left is making those pods reachable. This post covers that: the Gateway and UDPRoute, steering clients to STUNner, the two authentication layers, distributed scaling, and verification in production.

New to STUNner? How to Deploy STUNner as a WebRTC STUN/TURN Server on Kubernetes covers the first install. This post assumes a working deployment and picks up from there.

What STUNner changes for LiveKit

STUNner removes the constraints of host networking. LiveKit pods run behind an ordinary ClusterIP Service, and STUNner provides the single public TURN endpoint that clients relay media through. LiveKit’s own routing through Redis connects each client to the pod that holds its room, so STUNner never has to choose a pod. It relays each client’s media to the pod address that client negotiated.

The integration depends on one requirement. LiveKit has to advertise an address STUNner can relay to, and the client has to send its media through STUNner instead of directly to the pod. There are two ways to arrange that, a relay-only setup and a turn_servers setup, both covered below.

How LiveKit and STUNner split signaling and media.

Before configuring anything, it helps to understand the deployment architecture used throughout this post. LiveKit and STUNner separate signaling from WebRTC media.

The two paths

The deployment has two independent paths: a control path for signaling and room coordination, and a WebRTC media path for audio and video.

Diagram of LiveKit on Kubernetes without hostNetwork using STUNner. Signaling flows from the browser through an Ingress to LiveKit server pods, which coordinate room state in Redis. Media flows from the browser through a cloud load balancer to STUNner's stunnerd, which relays it directly to the LiveKit pods, discovering their addresses through the ClusterIP Service.
Signaling and media take separate paths. Dashed lines carry signaling and room coordination through the Ingress and Redis. The solid line carries media from the browser through STUNner’s TURN relay to the LiveKit pods.

Reading the diagram from left to right:

  • The control path (dashed) carries signaling. The browser connects to LiveKit over wss://, and the LiveKit pods coordinate room state through Redis so any pod can serve any room.
  • JWT connects the two paths. During signaling, LiveKit returns the room token and, depending on the deployment model, the TURN credentials the client needs for the media path.
  • The WebRTC media path (solid) stays off the pods’ public interfaces. The browser sends media to STUNner’s public TURN address on port 3478, and STUNner relays it to the LiveKit Service.
  • STUNner relays directly to the correct pod. The UDPRoute references the LiveKit Service only to discover and allow its endpoints. WebRTC media is relayed to the pod holding the session, not through the Kubernetes Service.

Prerequisites for running LiveKit behind STUNner

Before deploying this architecture, make sure the following prerequisites are in place:

  • A working STUNner deployment. Its GatewayClass must already exist; otherwise, the Gateway created in this guide never becomes ready.
  • A Redis instance that all LiveKit pods can reach. LiveKit stores distributed room state there.
  • DNS and TLS for two hostnames. Use one for signaling (i.e. livekit.<env>.<domain>) over wss:// and another for TURN (i.e. turn.livekit.<env>.<domain>). They serve different purposes and must not be mixed up. Refer to the STUNner post for more details on DNS and TLS.
  • A reserved public IP. The STUNner Gateway binds to this address to expose TURN.
  • A metrics stack. If you plan to autoscale, install metrics collection (for example, Prometheus and KEDA). Otherwise, LiveKit runs with a fixed replica count or manual scaling.

Integrating LiveKit with STUNner

With the architecture in place, the integration comes down to three pieces: exposing a public TURN endpoint, configuring LiveKit to advertise addresses STUNner can relay to, and sharing TURN credentials between the two systems.

The Gateway and UDPRoute: one public address, private pods

As stated in a previous post, two Kubernetes resources connect the two systems: a Gateway bound to the reserved public IP, and a UDPRoute that names the LiveKit Service as its backend.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: livekit-turn
  annotations:
    stunner.l7mp.io/enable-mixed-protocol-lb: "true"
spec:
  gatewayClassName: stunner-gatewayclass
  addresses:
    - type: IPAddress
      value: 203.0.113.10          # your reserved static IP
  listeners:
    - { name: turn-udp, port: 3478, protocol: TURN-UDP }
    - { name: turn-tcp, port: 3478, protocol: TURN-TCP }
---
apiVersion: stunner.l7mp.io/v1
kind: UDPRoute
metadata:
  name: livekit-turn
spec:
  parentRefs:
    - name: livekit-turn
  rules:
    - backendRefs:
        - name: livekit-server      # the LiveKit ClusterIP Service

The UDPRoute allow-lists the LiveKit Service. STUNner uses it to discover the LiveKit pod endpoints, then relays each client’s WebRTC media directly to the pod holding the session. The allow-list stays stable as pods come and go, which is why LiveKit scales cleanly behind STUNner.

The LiveKit pods remain private throughout. They run behind ordinary ClusterIP Services, without hostNetwork, node public IPs, or cloud metadata lookups. The only public endpoint is STUNner’s TURN address.

Ports: a single public entry for media

WebRTC media lands on a single UDP port in the deployment used throughout this guide. LiveKit multiplexes all WebRTC media on 7882/udp, and STUNner relays each client’s traffic directly to the appropriate pod. Alternatively, LiveKit can use the RTP port range (50000-60000) instead of UDP mux, with STUNner relaying media to the allocated port.

PortScopePurpose
3478 UDP/TCPpublic (STUNner)TURN — the only public entry
7880 TCPinternal / Ingresssignaling (WebSocket + REST)
7881 TCPinternal, on the ServiceRTC TCP fallback
7882 UDPinternal, on the ServiceRTC media mux
6789 TCPinternalPrometheus metrics

Steering LiveKit clients to STUNner: two methods

Two things have to line up. LiveKit has to advertise addresses STUNner can relay to, and clients have to send their WebRTC media through STUNner.

The field that controls the first is rtc.use_external_ip. For this deployment it must remain false, so LiveKit advertises pod IPs as ICE host candidates. Those are the addresses STUNner can relay to. If use_external_ip is true, LiveKit advertises the node or NAT external IP instead, which STUNner cannot reach since it is not on the UDPRoute backend list.

There are two supported ways to steer clients to STUNner which are mutually exclusive; choose one.

Method 1: relay-only

Relay-only keeps use_external_ip: false, leaves turn.enabled: false, and does not configure a turn_servers block. Instead, the client is configured to use TURN exclusively:

// client rtcConfig
iceTransportPolicy: "relay",
iceServers: [{ urls: "turn:turn.livekit.<env>:3478", ... }]  // STUNner's TURN creds

iceTransportPolicy: "relay" tells the browser to gather only relay candidates, so it never attempts the unroutable pod IPs. The TURN credentials come from the room JWT, described later.

Method 2: turn_servers

The official STUNner example takes the other approach by configuring a turn_servers block that points to STUNner’s public TURN endpoint with static credentials.

# livekit.yaml (rtc section)
rtc:
  turn_servers:
    - host: 203.0.113.10     # STUNner's public IP
      port: 3478
      protocol: udp
      username: user-1
      credential: pass-1

In this mode, LiveKit tells clients to use STUNner as their STUN/TURN server. LiveKit itself does not send its own media through STUNner; it simply advertises the TURN server for clients to use. Here, the turn_servers block replaces use_external_ip as the mechanism that drives the integration.

Choosing between relay-only and turn_servers

Relay-only pairs naturally with ephemeral, per-session TURN credentials minted into the room JWT (next section); it suits setups that rotate short-lived credentials and want the client locked to relay. The turn_servers style is simpler and matches the upstream STUNner demo: static credentials, fewer moving parts, and the server does the advertising. Either works, just don’t combine them. The remainder of this guide uses the relay-only approach.

Resulting livekit.yaml

Whichever approach you choose, LiveKit reads a single livekit.yaml. The configuration below shows the relay-only deployment example.

# livekit.yaml
port: 7880
logging:
  level: info
  json: false
rtc:
  tcp_port: 7881
  udp_port: 7882
  use_external_ip: false        # the advertising field, from above
turn:
  enabled: false                # STUNner is the TURN server, not LiveKit's embedded one
prometheus:
  port: 6789                    # metrics endpoint (see scaling)
redis:
  address: livekit-redis.example.com:6379
  use_tls: true
keys:
  <api-key>: <api-secret>       # layer-one auth, inlined

turn.enabled: false is important because STUNner replaces LiveKit’s embedded TURN server. The keys map contains the LiveKit API credentials, so this file is typically delivered as a Kubernetes Secret rather than a ConfigMap. The redis section points to the shared room directory: the redis instance. The turn_servers approach uses a similar configuration with an additional turn_servers section. Refer to the l7mp example for more details.

LiveKit TURN authentication: two layers, one shared secret

LiveKit behind STUNner uses two independent authentication layers:

  • The first layer is LiveKit’s application authentication. The server signs a room JWT with its API key and secret, and the client presents that JWT to join a room.
  • The second layer is TURN authentication. In the relay-only deployment used throughout this guide, LiveKit generates ephemeral TURN credentials during signaling and embeds them in the room JWT. The client receives both authentication layers in a single token.
username = <unix-expiry>:<user-id>
password = base64(HMAC-SHA1(shared-secret, username))

Both layers need these values: the LiveKit API key and secret, the shared TURN HMAC secret, and the Redis password which are typically stored in a Kubernetes Secret:

apiVersion: v1
kind: Secret
metadata:
  name: livekit-secrets
type: Opaque
stringData:
  LIVEKIT_API_KEY: <api-key>
  LIVEKIT_API_SECRET: <api-secret>
  TURN_CREDENTIALS: <shared-hmac>     # must equal STUNner's stunner-auth value
  redis-password: <redis-password>

The one requirement is that the TURN HMAC secret must be identical on both STUNner and LiveKit. LiveKit uses it to generate TURN credentials, and STUNner uses it to verify them. If the values differ, TURN allocation fails with 401 Unauthorized, preventing WebRTC media from being established.

How you store these values is up to you. They can live in one Kubernetes Secret, multiple Secrets, or an external secret manager such as External Secrets Operator or Vault. LiveKit and STUNner only require the same values; they do not depend on where those values are stored.

Running LiveKit in distributed mode with Redis

LiveKit’s distributed mode is what makes this deployment possible, and Redis is what enables it. Every pod registers in a shared room directory, so any pod can answer for any room while the SDK routes clients to the correct one. That’s what allows a single stable UDPRoute backend to front an elastic pool of LiveKit pods.

Redis is a dependency, not a cache. If it degrades, every LiveKit pod is affected. How you deploy it is up to you: point redis.address in livekit.yaml at your Redis endpoint, enable use_tls: true for a rediss:// endpoint, or configure Sentinel with a master name and addresses. For production, use a managed Redis service with failover (such as ElastiCache, Memorystore, or an equivalent) and verify failover before relying on it. A Redis outage is a cluster-wide event, not a single-pod failure.

Stable pod identity with a StatefulSet

Run the SFU as a StatefulSet so each pod receives a stable identity:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: livekit-server
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: livekit-server
          image: livekit/livekit-server:v1.9.1
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name

Stable pod names make logs, metrics, and Redis registrations easier to follow across restarts: livekit-server-0 is always livekit-server-0. The pod name is available through the POD_NAME environment variable, but LiveKit derives its own node ID for each process, so node_id does not need to be configured.

LiveKit autoscaling on active rooms with KEDA

Because LiveKit is distributed through Redis, scaling out is straightforward. New rooms are placed on available pods, and the UDPRoute backend continues to expose the same Service regardless of how many pods exist. A common approach is to use a Kubernetes Event-driven Autoscaler (KEDA) ScaledObject driven by LiveKit’s room-count metric:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: livekit-server
spec:
  scaleTargetRef:
    name: livekit-server
  minReplicaCount: 2
  maxReplicaCount: 6
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.observability:9090
        query: sum(livekit_rooms_total{namespace="livekit"})
        threshold: "30"

A few recommendations are worth keeping in mind when using the configuration above:

  • Keep minReplicaCount at 2 or higher so distributed mode continues to provide failover.
  • livekit_rooms_total is exported by LiveKit’s Prometheus endpoint on port 6789.
  • Prometheus discovers the pods through the standard scrape annotations (prometheus.io/scrape, prometheus.io/port, and prometheus.io/path).
  • If Prometheus or KEDA becomes unavailable, LiveKit remains at its minimum replica count, which is a safe failure mode.
  • With KEDA’s default AverageValue metric type, a threshold of 30 targets roughly 30 active rooms per pod.
  • Because the query uses a namespace-wide sum(), it assumes rooms are spread evenly across pods. If you need to react to hotspots on individual pods, scale on a true per-pod metric instead.
  • Treat the threshold as a starting point to validate with load testing, and avoid running multiple autoscalers against the same workload.

Scaling down: connection draining and the grace period 

When a pod is terminated during a scale down or a rolling update, LiveKit stops assigning it new rooms and lets existing calls finish. Kubernetes allows this only for the time set in terminationGracePeriodSeconds. After that, the pod is forcefully terminated and any remaining calls are dropped. Set the grace period to at least your longest expected call. LiveKit’s Helm chart defaults it to 5 hours for this reason. Because a StatefulSet may remove a pod hosting active calls, use a long HPA stabilization window and a slow scale-down policy.

Running LiveKit behind STUNner in production

Once the deployment is working, a few operational concerns apply regardless of how you package or deploy it.

  • Configuration needs to be managed as a whole. LiveKit reads its configuration from a mounted file (typically a Secret, since it contains credentials). Updating that Secret changes the file on disk, but the running process continues using the copy already loaded into memory. Something therefore needs to restart the workload when the configuration changes—a config-checksum annotation on the pod template, a controller such as Stakater Reloader, or your deployment pipeline triggering a rollout. Otherwise, a rotated HMAC or updated Redis endpoint has no effect until the next restart.
  • Deployment ordering matters. The Gateway cannot become ready until STUNner’s GatewayClass exists, and the StatefulSet cannot become ready until Redis is reachable. Whether you use Helm hooks, Argo CD or Flux sync waves, or simply apply manifests manually, make sure the STUNner control plane and Redis are available first. Otherwise, you’ll see resources waiting indefinitely: a Gateway with no assigned address or pods that never become Ready.

One way to package the setup is as a Helm chart: an umbrella chart over a shared subchart, with the SFU and its web app client installable independently, and egress, ingress, and SIP disabled by default. Alternatively, the l7mp/livekit-operator packages LiveKit, Redis, and the STUNner Gateway and UDPRoute into a single custom resource for greenfield deployments and demos. Both produce the same underlying Kubernetes resources, so Kustomize or plain manifests work equally well.

Verifying and troubleshooting LiveKit behind STUNner

At this point, the deployment is complete: LiveKit is running privately behind STUNner, with distributed room routing through Redis and clients relaying all WebRTC media over TURN. To verify it, establish a call between two participants using two browsers, with one participant using Google Chrome. Then open chrome://webrtc-internals in Chrome and inspect the selected ICE candidate pair. It should be relay, confirming that the browser is sending WebRTC media through STUNner rather than attempting a direct connection.

If the deployment does not behave as expected, start with these LiveKit-specific checks:

  • TURN allocation returns 401 Unauthorized: The shared TURN HMAC differs between LiveKit and STUNner. Compare the two values directly.
  • host or srflx is selected in chrome://webrtc-internals: The client is not using relay-only mode. Check iceTransportPolicy: "relay".
  • The StatefulSet never becomes Ready: Redis is usually unreachable. Verify the endpoint, credentials, and connectivity.
  • Signaling works but TURN does not, or vice versa: The signaling and TURN hostnames have been swapped. Signaling uses the wss:// hostname; TURN uses the turn. hostname.

For general STUNner troubleshooting (Gateway has no address, UDPRoute not accepted, mixed-protocol issues, dataplane restarts), see Configuring STUNner on Kubernetes.

LiveKit on Kubernetes without hostNetwork: the core idea

LiveKit runs on Kubernetes without hostNetwork when the media path lines up end to end. LiveKit advertises pod addresses that STUNner can relay to, and clients send their media through STUNner. STUNner owns the media entry point, LiveKit owns the rooms, and Kubernetes manages the lifecycle of both. The pods stay on an ordinary ClusterIP Service, scale through Redis, and report through Prometheus.

That is the core idea: one public entry point for media, private LiveKit pods behind it, and three details to settle before production: the advertised address, the shared HMAC secret, and the graceful drain.

Egress, WHIP/RTMP ingress, and SIP/PSTN are next, each in its own post.

If you’re planning a LiveKit deployment on Kubernetes and want help getting it production-ready, WebRTC.ventures can help, whether that’s STUNner integration, scaling, egress and ingress pipelines, or SIP connectivity. We build and operate real-time communication infrastructure for teams that need it to work reliably at scale. Get in touch to talk about your setup.

Further Reading:

Recent Blog Posts