Coturn and Kurento in Docker Compose: a working network layout
Coturn and Kurento should not share the same networking assumptions. Coturn needs public UDP reachability and a large relay-port range. Kurento needs a stable internal signaling endpoint and a public media address that does not expose Docker's private network.
Separate the three concerns
A working deployment has three different paths:
- Browser to coturn: direct UDP/TCP on 3478 and a relay range.
- Browser to Kurento media: direct RTP/RTCP, with a public address advertised in SDP.
- Spring Boot to Kurento signaling: WebSocket traffic inside the private Compose network.
The first two paths are media paths. You cannot validate them by confirming that HTTPS, signaling or Docker DNS is working.
Keep coturn on host networking
On a typical Linux VPS, network_mode: host is the least surprising starting
point for coturn. A bridge network can work, but it adds another NAT layer in front of the
relay range and makes candidate debugging harder.
When host networking is used, remove the ports: block. Docker does not publish
host-network ports, and leaving those mappings in the file makes it look as if port
translation is happening when it is not.
# /etc/coturn/turnserver.conf
listening-port=3478
fingerprint
use-auth-secret
static-auth-secret=replace-with-a-long-random-secret
realm=turn.example.com
external-ip=203.0.113.10
min-port=49152
max-port=65535
verbose
no-cli
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"]
If the VPS has a private interface and the provider maps a public address to it, use the
paired form of external-ip:
external-ip=203.0.113.10/10.0.0.4
Replace 10.0.0.4 with the address coturn actually sees on the host. Public
first, local second. Without this mapping, the relayed candidate can contain an address
that no remote peer can route to.
Keep Kurento on the Compose network
Kurento should not use host networking just because coturn does. Its WebSocket endpoint is a private service consumed by the Spring Boot application, and its media path needs an explicit public address.
services:
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
server-backend:
build: .
ports:
- "8090:8080"
depends_on:
- kurento
networks:
- server
networks:
server:
driver: bridge
The distinction between the two addresses matters:
KMS_EXTERNAL_IPV4is the public address Kurento advertises for media.kurento:8888is the private service name used by Spring Boot inside Compose.turn.example.comis the public hostname browsers and Kurento use as their ICE server.
If Kurento advertises a 172.x or 10.x address in its SDP, a remote
browser has no route to it. The signaling connection may still look healthy because the
browser is talking to the public application origin, not to that private media address.
Do not confuse realm, hostname and relay address
These settings have different jobs:
- realm: the authentication realm coturn sends during the TURN challenge.
- external-ip: the address returned in relay candidates when coturn is behind NAT.
- client URL: the public hostname or address placed in the browser's ICE server configuration.
Use a stable hostname such as turn.example.com for the realm and the client
URL. Use the public IP for external-ip when coturn cannot discover it itself.
The realm is not the public IP, and changing it is an authentication-context change.
Choose one TURN credential mode
Coturn supports static long-term credentials and time-limited REST credentials. They are alternative mechanisms, not two layers to enable casually.
For a development box, lt-cred-mech with an explicit test user is simple.
For a public WebRTC application, use-auth-secret is usually the better fit:
the application server generates a short-lived credential for each client.
username = <unix-expiry>:<label>
password = base64(HMAC-SHA1(secret, username))
If you switch to use-auth-secret, remove static user= lines
unless a separate internal credential path actually needs them. Do not ship a long-lived
shared browser credential inside JavaScript.
Kurento is an internal client of the TURN service. If it needs a fixed URL, give that path its own tightly scoped credential and rotate it deliberately. Do not reuse a browser credential for an internal service.
Open the relay range in both firewalls
Port 3478 is the control port. RTP does not flow over it. Coturn allocates a relay port for
each session from the configured UDP range, normally 49152-65535.
Open that range in both the provider security group and the host firewall. A successful TURN allocation only proves that the control exchange worked; it does not prove that the relay can carry media.
A ten-port range is a lab configuration. Concurrent allocations can consume more than one port per calling user, and retries make the demand less predictable. Keep the default range or size a narrower range from measured concurrency.
Keep UDP first and add fallbacks deliberately
Start with:
turn:turn.example.com:3478?transport=udp
Add TCP on 3478 for networks that block UDP. If you terminate TLS for TURN, add
turns: on 5349 or 443. Do not put TURN behind an HTTP reverse proxy: TURN is
not HTTP, and a generic proxy configuration will not relay it.
Give Nginx only the application boundary
Nginx can terminate public HTTPS and WSS for the application. It can also proxy the Kurento WebSocket over the private Compose network when that endpoint must be exposed to the browser through the product.
Keep port 8888 unexposed to the public internet. Keep coturn's control and relay ports outside the HTTP proxy. The reverse proxy belongs on the application path, not on the media relay path.
Verify the deployment in this order
- Confirm coturn is listening on UDP 3478 and the configured relay range.
- Confirm both the provider firewall and the host firewall allow the same relay UDP range.
- Use the Trickle ICE page and confirm that a
relaycandidate appears. - Set
iceTransportPolicy: "relay"in a test browser and confirm that a call still connects. - Inspect Kurento SDP and confirm that it advertises the public media address, not a Docker address.
- Test from cellular data or another network, not from the VPS's own LAN.
The common failure is not "Docker is broken." It is a public service configured with an internal address, a relay range that is open in only one firewall layer, or a browser credential mode that does not match the server's authentication mode.
The Low-Latency Kit source package includes a Go signaling server and SFU, browser clients, reconnect handling, diagnostics and deployment notes for a self-hosted real-time video path.