STUNner is a Kubernetes-native TURN server, TURN being the relay protocol that gives WebRTC clients a public endpoint to send media through. It builds on the Gateway API to terminate TURN at a single load balancer endpoint and relay media to the pod behind it, so media-server pods stay on ordinary ClusterIP Services, cluster-internal only, with no public IPs of their own. Examples in this post use Elastic Kubernetes Service (Amazon EKS), but the resource model is the same across clouds, so most of it carries over directly.

This pattern applies to any WebRTC media server running privately on Kubernetes. We used it recently to expose a pool of Janus instances behind a Media Resource Broker, where each room is pinned to one specific instance rather than freely load-balanced. That distinction shapes how STUNner’s routing model needs to be understood.

New to STUNner? How to Deploy STUNner as a WebRTC STUN/TURN Server on Kubernetes covers installing the control plane and a first TURN server with static credentials. This post picks up from there: running STUNner in production, with ephemeral credentials, multi-tenant Gateway setups, and rotating the shared secret without downtime

From Architecture to Configuration: STUNner’s Gateway API Resource Model

A Relay, Not a Load Balancer

Before writing a single line of configuration, there is one key idea to understand: STUNner does not load balance media. Its job is to relay media to the configured backend pods: the media servers.

STUNner consists of two main components: the control plane and the data plane.

  1. The control plane is the STUNner operator. It watches the STUNner custom resources and, when the configuration is valid, creates the runtime components — the relay pods and the load balancer that exposes them.
  2. The data plane consists of the stunnerd pods. These are the components that speak the TURN protocol (handling relay allocations, permissions, and media forwarding). A browser sends TURN traffic to a stable load balancer IP. stunnerd validates the client’s credentials, accepts the relay connection, and forwards the media to the target pod.

By the time media reaches STUNner, the client already knows which pod it wants to reach. STUNner simply validates the TURN request and relays the media to that destination. It does not select a backend, retry another pod, or distribute traffic across replicas.

This distinction is important. STUNner provides clients with a stable public relay endpoint, while the media stack (whether the media server itself or a companion service like Jitsi’s Jicofo) remains responsible for deciding which pod owns a session.

The following diagram illustrates the main components and the flow of media to the media server pods.

Diagram of STUNner's Gateway API TURN relay architecture on Kubernetes, showing the browser connecting through a cloud load balancer to stunnerd, with GatewayClass, GatewayConfig, and Secret resources in the control plane, relaying media to a ClusterIP Service and media pods.
How STUNner’s control plane (GatewayClass, GatewayConfig, and the shared HMAC Secret) wires up stunnerd to relay TURN traffic from the browser to the correct media pod.

The solid line towards the bottom shows the media path. Traffic starts in the browser, reaches STUNner through the cloud load balancer, passes through the stunnerd pods, and then continues as UDP inside the cluster to the selected media pod.

The dashed lines show the control-plane work done by the STUNner operator. During reconciliation, the operator creates the stunnerd dataplane, creates the load balancer service, and connects the resource references so stunnerd knows which secret to use for credential validation.

Once the control-plane references are correct, the operator builds and connects the runtime pieces for you. The configuration work is about getting those references right.

Two API Groups That Look Like One

STUNner is modeled as a set of four Kubernetes resources: GatewayClass, Gateway, GatewayConfig, and UDPRoute. They sit next to each other and read like one connected model, but there is an important detail to notice: they come from two different API groups. GatewayClass and Gateway come from the upstream Gateway API (a standard Kubernetes extension for managing ingress and routing), while GatewayConfig and UDPRoute are STUNner-specific custom resources. Kubernetes treats them as separate resource types with different owners and different rules.

# Upstream Gateway API — gateway.networking.k8s.io/v1
kind: GatewayClass     # cluster-scoped; names the controller
kind: Gateway          # per media server; TURN listeners + static address
 
# STUNner's own group — stunner.l7mp.io/v1
kind: GatewayConfig    # auth mode, realm, load balancer annotations
kind: UDPRoute         # allow-list of backend Services

This matters as soon as you write role-based access control (RBAC) rules, admission policies, or audit queries. Access to one API group does not automatically cover the other. If you grant permissions for Gateway resources but forget the STUNner resources, the failure looks like a normal permission error until you notice that the API group in the error message is different from the one you configured.

The UDPRoute Is an Allow-List, Not a Pool

The relay-not-balance behavior described above is not just a mental model. It is encoded in the one resource that defines where media may go: the UDPRoute backend reference.

One important distinction: STUNner’s UDPRoute belongs to the stunner.l7mp.io/v1 group, not the upstream Gateway API gateway.networking.k8s.io. They share a name, but they are different resources.

backendRefs:
  - name: media-server
    namespace: media        # no port: the client's peer address decides it

A UDPRoute backend reference looks like a normal Service backend, so it is tempting to read it as a load balancer pool. It is not. It is an allow-list: it tells STUNner which backend pod IPs it is permitted to relay to. Notice there is no backend port field — stunnerd does not forward to a fixed port. It relays to the peer address the client supplies in its TURN Allocate request, and checks that requested peer against the pod IPs behind the listed Services. A match is relayed; anything not on the list is dropped.

One detail on the allow-list: if multiple backend Services are listed in the backendRefs block, they form a single permitted set (the union of their pod IPs) rather than a round-robin pool.

Who Owns What: Cluster vs. Media Server Resources

Knowing what resources exist raises the next question: who applies them, and when?

STUNner configuration is split into two ownership tiers: cluster-infrastructure and per-media server.

The cluster-infrastructure tier consists of GatewayClass and GatewayConfig. Simply put, there is one of each for the cluster, both created in the STUNner system namespace. These are applied once during bootstrap and rarely change after that. This is where the shared STUNner settings live including the authentication mode, the reference to the shared secret, and the load balancer annotations that the operator applies to every Service it creates.

# platform tier — created once, in the STUNner system namespace
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: stunner-gatewayclass
spec:
  controllerName: stunner.l7mp.io/gateway-operator
  parametersRef:                     # points at the GatewayConfig below
    kind: GatewayConfig
    name: stunner-config
    namespace: stunner
---
apiVersion: stunner.l7mp.io/v1
kind: GatewayConfig
metadata:
  name: stunner-config
  namespace: stunner
spec:
  authRef:                           # points at the shared-secret Secret
    name: stunner-auth-secret
    namespace: stunner
  loadBalancerServiceAnnotations: {} # cloud LB annotations, applied to every Service

The per-media-server tier includes Gateway, UDPRoute, and the auth wiring. A Gateway declares the TURN listeners and the static address STUNner should use. A UDPRoute attaches to that Gateway and points at the media server’s backend Service.

# per-media-server tier — in the media server's namespace
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: media-server-gateway
  namespace: media
spec:
  gatewayClassName: stunner-gatewayclass
  addresses:
    - type: IPAddress
      value: <static-LB-IP>          # optional; recommended for production
  listeners:
    - name: turn-udp
      port: 3478
      protocol: TURN-UDP
---
apiVersion: stunner.l7mp.io/v1
kind: UDPRoute
metadata:
  name: media-server-route
  namespace: media
spec:
  parentRefs:
    - name: media-server-gateway
  rules:
    - backendRefs:
        - name: media-server   # the Service fronting your media pods

One field on the Gateway deserves attention: the static load balancer IP. It is optional, but recommended for production on cloud setups because it keeps the DNS record pointing at STUNner stable across Service re-creations. The cluster does not reserve this IP for you; you reserve it through the cloud provider (e.g., an Elastic IP on AWS) and pass it into the Gateway, and the operator turns it into the load balancer annotation that binds the Service to that IP (see the load balancer section for the EKS example).

For development or temporary environments, you can let the load balancer get its default IP or DNS name. Just remember that if the Gateway Service is recreated, that address may change and DNS must be updated.

Finally, the ownership boundary is also a namespace boundary. The shared infrastructure stays in the STUNner system namespace, and your media server’s resources live in its own namespace.

Trusting the Traffic: Authentication and Multi-Tenancy in Production

STUNner Ephemeral Credentials: How Clients Prove They Belong

Before any media flows through the shared infrastructure, STUNner has to trust the incoming traffic. Authentication for TURN sessions is done using two modes:

  • Static credentials: one fixed username and password for every client. This is useful for development/testing, but unsafe for production because a leaked credential has no natural expiry.
  • Ephemeral credentials: each client gets a short-lived username and password generated from a shared key. This is the production mode and follows the TURN REST API credential scheme. This is a widely supported pattern where credentials are time-limited and derived from a shared secret.

In ephemeral mode, the same shared key is used on both sides. The media server’s signaling plane (the component that coordinates session setup between clients) uses it to mint a credential and sends that credential to the browser. stunnerd uses the same key to validate it.

The credential takes the following shape:

username = <expiry-timestamp>:<user-id>
password = base64( HMAC-SHA1( shared-secret, username ) )

The username contains the expiry timestamp. The password is an HMAC of that username using the shared secret. When the browser connects, stunnerd recomputes the same HMAC and accepts the credential only if the password matches and the expiry is still in the future.

How the credential is minted depends on the media server. Jitsi may use Prosody, LiveKit may derive it through its room-token flow, and Janus may use an application server. The minting method varies; the contract does not:

media server:  mints credential from shared key
stunnerd:      validates credential against the same key

Most authentication failures come from one simple problem: the two copies of the shared key do not match. If they differ by even one byte, stunnerd rejects the credential, the TURN request fails as unauthorized, and the call may hang without an obvious “wrong key” error. When debugging this, the two values need to be compared directly before chasing downstream symptoms.

Rotating the TURN Shared Secret in Production

That shared key is the linchpin, so where it lives and how you rotate it matters.

Usually it is stored as a Kubernetes Secret. By default, that means the value is base64-encoded (a transport encoding, not encryption) unless the cluster has encryption at rest configured. Any workload or user that can read Secrets in the STUNner system namespace can read this key.

apiVersion: v1
kind: Secret
metadata:
  name: stunner-auth-secret
  namespace: stunner-system
type: Opaque
stringData:
  type: ephemeral
  secret: <shared-HMAC-key>   # identical to the media server's copy

Treat that namespace as sensitive infrastructure. Limit access from other workloads, broad cluster readers, and developer credentials. If your threat model includes access to etcd backups or the cluster backing store, enable Kubernetes Secret encryption at rest.

External secret managers such as Vault can harden this further, but they are not mandatory. You can either reconcile the value into a Kubernetes Secret or inject it at pod admission. That removes or reduces the long-lived copy in the cluster, but adds an operational dependency. A plain Kubernetes Secret with tight RBAC and encryption at rest is still a valid choice for many deployments.

Rotation is the part that needs the most care because both sides must change together. If STUNner and the media server use different keys, every new TURN credential fails immediately.

A safe rotation sequence is as follows:

  1. Replace the shared key on the STUNner side.
  2. Restart the stunnerd dataplane so it loads the new value.
  3. Update the media server or credential-minting service.
  4. Roll the minting side so new credentials use the new key.

Existing TURN allocations can continue until their embedded expiry, so the cutover is gradual. New credentials must use the new key.

The restart steps matter. stunnerd keeps TURN allocation state (active relay sessions) in memory, so rolling the dataplane drops active calls using those pods. Clients should reconnect and allocate again, but users may see a brief interruption. The same applies to any change that rolls the dataplane, including image updates and configuration edits, so plan such rotations and upgrades for low-traffic windows.

Also restart the right pods. The stunnerd dataplane does not run in the operator’s system namespace. The operator creates one dataplane Deployment per Gateway in the media server’s namespace. Restart the stunnerd pods there, using the labels applied by the operator.

Multi-Tenant STUNner: One Gateway per Media Server

Everything so far describes the setup for a single media server: one Gateway and one UDPRoute, which may front multiple replicas of the same media server (e.g., several Janus instances behind one Service). When you need to run separate media deployments — say, a Jitsi cluster alongside a LiveKit cluster, or the same platform in separate environments — repeat the pattern: each deployment gets its own Gateway and UDPRoute in its own namespace, sharing the same cluster-level GatewayClass and GatewayConfig.

Diagram of multi-tenant STUNner on Kubernetes, showing one shared GatewayClass and GatewayConfig in the stunner namespace feeding separate Gateway and UDPRoute pairs in the livekit and jitsi namespaces, each routing to its own media pods.
Multi-tenant STUNner: one shared GatewayClass and GatewayConfig, with each media server getting its own Gateway and UDPRoute in its own namespace.

This keeps credentials, metrics, restarts, and failures scoped to one media-server instance. The tradeoff is cost: one load balancer per instance per environment. You can reduce that by sharing one Gateway with multiple routes to different backends, but that also shares auth credentials and increases blast radius, so keep it to development or staging. If you run multiple tenants, give each one its own tenant-scoped GatewayClass instead of letting them all default to the same class.

Reaching the Outside World

The Load Balancer Layer (EKS Example)

Everything covered so far has been mostly cloud-agnostic. The main differences show up at the load balancer layer, where the cluster meets the public internet.

On EKS, each STUNner Gateway is exposed through a Kubernetes Service backed by an AWS Network Load Balancer. The AWS Load Balancer Controller provisions the NLB, attaches the reserved Elastic IPs, and DNS points users to those addresses.

The STUNner operator creates the Service and applies the load balancer annotations from the platform-owned GatewayConfig. Most annotations are common; the following are some important ones:

kind: Service   # operator-stamped, type LoadBalancer
spec:
  externalTrafficPolicy: Local   # preserve the real client IP
  loadBalancerClass: service.k8s.aws/nlb
metadata:
  annotations:
    ...aws-load-balancer-eip-allocations: "eipalloc-<azA>,eipalloc-<azB>"
    ...aws-load-balancer-cross-zone-load-balancing-enabled: "true"
    ...aws-load-balancer-enable-tcp-udp-listener: "true"   # both protocols, one listener

The most important setting is externalTrafficPolicy: Local, which preserves the real client IP. STUNner needs that source address for STUN binding responses (the mechanism clients use to discover their public-facing address); if the load balancer rewrites it, connectivity failures can be subtle and hard to trace.

The remaining annotations handle the AWS-specific plumbing: routing traffic directly to pod IPs and spreading the load balancer across availability zones.

Stable Public IPs with Elastic IPs

Starting with public access on EKS, reserve the required Elastic IPs (EIPs) and pass their allocation IDs to the load balancer Service. When a deployment spans multiple Availability Zones (AZs), reserve one Elastic IP for each AZ used by the load balancer.

The key requirement is that the number of Elastic IPs matches the number of load balancer subnets.

Elastic IPs are region-scoped rather than tied to individual subnets. With cross-zone load balancing enabled, it does not matter which Elastic IP is associated with which Availability Zone.

In cases where the Service remains in the Pending state, first verify that the number of Elastic IPs matches the configured subnets. Then confirm that both the Elastic IP allocation IDs and the subnet IDs are valid.

Both Protocols on the Same Port

TURN clients on restricted networks (corporate VPNs, hotels) cannot use UDP and need a TCP fallback. If the load balancer can serve both protocols on the same port, you get one endpoint for all clients. If it cannot, you need two separate endpoints. This is the main cloud-specific decision at this layer.

A STUNner Gateway usually exposes TURN over UDP and TCP on port 3478. You can also add TURN over TLS, commonly on port 5349, or sometimes on port 443 for restrictive networks that only allow TLS-like traffic.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  ...
spec:
  gatewayClassName: stunner-gatewayclass
  listeners:
    - name: turn-udp
      port: 3478
      protocol: TURN-UDP
      allowedRoutes:
        namespaces:
          from: All
    - name: turn-tcp
      port: 3478
      protocol: TURN-TCP
      allowedRoutes:
        namespaces:
          from: All
    - name: turn-tls
      port: 443 # or 5349
      protocol: TURN-TLS
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            namespace: stunner
            name: tls-secret
      ...

UDP is the normal path for real-time media. TCP provides a fallback for restricted networks, such as corporate VPNs, hotels, or networks that block UDP traffic. TURN over TLS is optional, but useful in stricter environments where encrypted traffic is more likely to pass.

Getting both protocols onto the same port is not something STUNner does on its own. Same port TCP and UDP support depends on two annotations working together.

One annotation goes on the STUNner Gateway (below). It tells the STUNner operator to expose both same-port listeners instead of collapsing them.

metadata:
  annotations:
    stunner.l7mp.io/enable-mixed-protocol-lb: "true"

The other goes on the load balancer Service, usually through the platform-owned GatewayConfig so the operator stamps it onto the Service. On EKS, the AWS Load Balancer Controller reads it to create a combined TCP/UDP listener when a Service defines TCP and UDP on the same port.

service.beta.kubernetes.io/aws-load-balancer-enable-tcp-udp-listener: "true"

If the Gateway-side annotation is missing, STUNner keeps only the first listener on port 3478 and drops the other one. In the usual config, UDP is listed first, so TCP disappears.

If the cloud-side annotation is missing, the load balancer cannot put TCP and UDP on the same port.

Either way, the result is the same: UDP works, but TCP-only networks fail because there is no TCP listener on port 3478 (and vice versa if the TCP listener came first). Check these annotations when STUNner works on open networks but fails on restricted ones.

What Changes Across Cloud Providers

Across cloud providers, the main difference is whether the load balancer can carry both protocols on a single same-port listener.

On modern AWS and Azure, this is possible. A single Gateway with the mixed-protocol annotation provides one Service, one load balancer, one public IP, one DNS record, and one hostname for both transports.

Where it cannot (older AWS load balancer controllers and GCP), deploy one Gateway per protocol. This creates two Services, two load balancers, and two public IPs. Each protocol gets its own DNS record—a UDP host and a TCP host—and both are passed to the client’s iceServers configuration (the browser’s list of TURN servers to try during connection setup).

Inside the cluster, nothing changes. STUNner’s architecture, authentication flow, and media relay path remain the same.

There are two rules that apply everywhere. First, the load balancer class should be selected before creating the Service, as it is immutable after creation. Second, do not proxy the TURN DNS record through an HTTP or CDN proxy — TURN traffic is not HTTP, and proxies that inspect or buffer it will break the relay.

When Things Break

When STUNner breaks, start with the resource chain instead of guessing. Check it from top to bottom: GatewayClass, GatewayConfig, Secret, Gateway, UDPRoute, and the generated Service. The failing layer usually points to the real problem.

Common symptoms:

  • The Gateway never gets an address: the operator has not reconciled, or the load balancer controller cannot claim the static IP. Check the controller logs and confirm the IP was reserved.
  • The UDPRoute is not accepted: the backend Service is missing or in the wrong namespace.
  • The relay request is unauthorized: the shared key used by the media server does not match the key used by stunnerd. Compare the two values directly.
  • UDP works, but restricted networks fail: one side of the mixed-protocol annotation pair is missing, so TCP on port 3478 is not reachable.
  • Calls drop after a config change or image update: the stunnerd dataplane rolled and lost its in-memory TURN allocation state.

The main rule is simple: troubleshoot in the same order the resources depend on each other. The reference chain is also the diagnostic path.

STUNner in Production on Kubernetes: The Core Idea

The full setup is straightforward once the pieces are separated. The shared GatewayClass and GatewayConfig are applied once, pointing STUNner at the shared-secret Secret. The media server then adds its own Gateway and UDPRoute in its own namespace.

From there, the operator creates the runtime pieces: a stunnerd Deployment and a load balancer Service for the Gateway. The Gateway exposes TURN on port 3478, and the UDPRoute defines the allowed backend pod IPs.

At runtime, the media server gives the browser a short-lived TURN credential minted from the shared key. stunnerd validates that credential, accepts the relay at the stable public IP, and forwards media to the exact pod chosen earlier by the signaling plane.

That is the core idea: one public media entry point, normal Kubernetes pods behind it, and a small set of configuration details that decide whether the setup is only good for a demo or ready for production.

If you’re running WebRTC media infrastructure on Kubernetes and want help taking it from working to production-ready, WebRTC.ventures can help. 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.

Recent Blog Posts