DEV Community

AiLaoHuYu
AiLaoHuYu

Posted on

Coturn and Kurento in Docker Compose: the three addresses that decide everything

A containerised TURN and media stack fails in a way that is genuinely confusing: every container is running, the logs look clean, the ports are published, and no remote peer can get media. The cause is almost always that three different addresses were treated as one.

They are not interchangeable. Each one answers a different question, and mixing them up produces silent failure rather than an error.

The whole thing in one line: realm is for authentication, external-ip is for relay candidates, and the advertised media address is for SDP — a stack that works needs all three correct, and none of them is a synonym for the others.

Address 1: the realm

realm is not an address at all in the transport sense. It is the authentication realm coturn sends in its TURN challenge, and it participates in long-term credential authentication. A stable hostname is easier to operate than an IP:

realm=turn.example.com
Enter fullscreen mode Exit fullscreen mode

Leave it empty and every request is challenged with nothing, the client retries forever, and you get a wall of 401 Unauthorized with a client-side 701 timeout. The startup log says so once — Default realm: followed by nothing — and then never mentions it again. Read the ten lines before the first client request, not the hundred after it.

Address 2: external-ip

This is the address that appears in relay candidates. On a cloud VM, coturn usually sees only a private interface while the public address is attached by the provider's NAT, so it must be told:

external-ip=203.0.113.10/10.0.0.4
Enter fullscreen mode Exit fullscreen mode

The paired form is the one people miss. 203.0.113.10 is the public address, and 10.0.0.4 must be the address coturn actually sees on the host. Get the second half wrong and coturn does not map the interface, the relay candidates stay private, and remote peers try to reach an address that only exists inside the VPC.

Address 3: the media address Kurento advertises

Media servers have their own version of this problem, made worse by containers. A containerised Kurento is happy to advertise the 172.x address of its Docker interface in SDP, and no remote peer can route to it:

  kurento:
    image: kurento/kurento-media-server:7.1.0
    restart: unless-stopped
    expose:
      - "8888"
    environment:
      KMS_STUN_IP: turn.example.com
      KMS_STUN_PORT: "3478"
      KMS_TURN_URL: "turn:turn.example.com:3478?transport=udp"
      KMS_EXTERNAL_IPV4: "203.0.113.10"
    networks:
      - server
Enter fullscreen mode Exit fullscreen mode

KMS_EXTERNAL_IPV4 is the public address Kurento puts in its SDP. Without it you get a session that negotiates, reports connected, and carries nothing — the classic "works locally, dead from a remote browser" shape.

Two related traps in the same file. KMS_STUN_IP has to be reachable from inside the Kurento container, so 127.0.0.1 and localhost are wrong there. And ws://kurento:8888/kurento is correct for your application service on the Compose network, but it is not a public endpoint and it is not a browser URL — that mistake looks like a firewall problem and is not one.

Give coturn host networking, and then drop the ports: block

On a Linux VPS, network_mode: host is the least surprising option for coturn, because the relay range is a wide UDP span that bridged networking handles badly:

services:
  coturn:
    image: coturn/coturn:4.6.3
    network_mode: host
    restart: unless-stopped
    volumes:
      - ./turnserver.conf:/etc/coturn/turnserver.conf:ro
    command: ["-c", "/etc/coturn/turnserver.conf"]
Enter fullscreen mode Exit fullscreen mode

With host networking the container shares the host network namespace, so a ports: block is not just redundant — it is misleading, because it suggests Docker is publishing something that is already directly exposed. Remove it and let the config file own the port and range.

Choose one credential mode, not two

The other recurring mistake is layering both credential mechanisms and then wondering which one the client used:

  • Development: lt-cred-mech with an explicit user=.
  • Production: use-auth-secret with short-lived timestamp:label usernames and HMAC-SHA1 passwords.

If you move to use-auth-secret, remove the static user= lines unless you have deliberately created a separate path for an internal client.

username = <unix-expiry>:<label>
password = base64(HMAC-SHA1(secret, username))
Enter fullscreen mode Exit fullscreen mode

And keep the browser's credential out of the browser. The browser should receive credentials from your signaling backend, minted per session, because a long-lived credential embedded in JavaScript is a credential you cannot rotate.

Note that KMS_TURN_URL is used by Kurento itself, not by the browser. If Kurento needs a fixed URL, give it its own tightly scoped credential, rotate it deliberately, and do not reuse it for browser clients.

The relay range is a capacity number, not a formality

Each concurrent TURN allocation consumes a relay port, and retries or multiple allocations can consume more than one. A ten-port range is a test configuration:

min-port=49152
max-port=65535
Enter fullscreen mode Exit fullscreen mode

That default is safe and large. A small production deployment can run a few hundred ports once you have measured concurrency — but open the range in both firewalls, the provider firewall and the host firewall. A control port that answers while the relay range is closed produces a srflx candidate and no relay candidate, which the browser reports as a generic failure with no hint about which of the two is blocked.

Do not match Kurento's internal RTP range to coturn's. They are different pools with different owners.

Transport order, and what Nginx should not do

Prefer UDP, then add fallbacks deliberately: TCP on 3478, and TLS on 5349 or 443 if you have certificates, for networks that block UDP.

turn:turn.example.com:3478?transport=udp
Enter fullscreen mode Exit fullscreen mode

Do not put TURN behind an HTTP reverse proxy. TURN is not HTTP. Nginx can terminate HTTPS/WSS for your application and proxy Kurento's WebSocket over the private Compose network, but the TURN listeners must remain direct UDP/TCP services, and Kurento's port 8888 should stay unexposed to the internet.

The verification order worth following exactly

  1. Confirm the host is listening on 3478/UDP and on the configured relay range.
  2. Confirm the provider firewall and host firewall allow that same range.
  3. Run a Trickle ICE check and confirm a relay candidate actually appears — not just an srflx one.
  4. Set iceTransportPolicy: "relay" in a test browser and confirm a call still connects. That proves the relay path end to end.
  5. Inspect the Kurento SDP and confirm it advertises KMS_EXTERNAL_IPV4, not a Docker address.

Step three and step four are the ones people skip, and they are the only two that distinguish "TURN is running" from "TURN works". A relay that answers STUN and refuses allocations passes every health check you would think to write.

If you want to run that check without setting up a browser test, the relay test on my site takes a TURN URL and credentials and reports which candidate types came back, and it is free and needs no account:

https://lowlatencykit.com/test/turn/

The longer-lived version of this article, with the complete Compose file, lives as a guide here:

https://lowlatencykit.com/guides/coturn-kurento-docker/

And the self-hosted WebRTC kit this came out of — Go signaling server, SFU, browser clients and the coturn configuration — is here:

https://lowlatencykit.com/

Top comments (0)