A small and simple TURN/STUN relay for a self-hosted Matrix homeserver, built on pion/turn. One-binary approach, perfect companion to conduit forks to build a fully functional self-hosted Matrix server with working audio/video calls for your community.
It exists to replace coturn in a setup where coturn's configuration surface is far larger than a single homeserver needs. Everything here is one purpose: relay WebRTC media for the users of your own homeserver, and refuse everyone else.
- Authenticates with the TURN REST API shared-secret scheme — the same thing
coturn calls
static-auth-secretand your homeserver callsturn_secret. No user database, no state. - Restricts relaying to users of your homeserver. A credential is only accepted if the Matrix user id inside it belongs to a domain you configured.
- Refuses to relay into private address space by default, so an authenticated client cannot use the relay as a way into your LAN.
- Runs in the foreground and logs to stdout. No forking, no pidfile, no log files. Supervise it with systemd or a container runtime.
makeThat produces a statically linked binary. Use it rather than a bare
go build if the machine you build on is not the machine you run on: with cgo
enabled (the Go default) the binary links against the build host's glibc and
dies on an older one with
./ver: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found
The equivalent by hand, and a cross-build for an ARM box:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o mtxturn .
make build-arm64Every option has a flag and an environment variable. Flags win.
The shared secret is the one exception: it is read from
$TURN_STATIC_AUTH_SECRET or -secret-file, and deliberately cannot be
passed as a flag, because command line arguments are visible to every user on
the machine through ps.
export TURN_STATIC_AUTH_SECRET="$(openssl rand -hex 32)"
./mtxturn \
-public-ip 203.0.113.10 \
-matrix-domain example.org \
-min-port 49152 -max-port 65535| Flag | Env | Default | Meaning |
|---|---|---|---|
-public-ip |
TURN_PUBLIC_IP |
(required) | Public IPv4 address clients send relayed media to. Behind NAT this is the router's WAN address, not the host's. |
-listen-ip |
TURN_LISTEN_IP |
0.0.0.0 |
Comma separated addresses to accept clients on. Use 0.0.0.0,:: for both families. |
-relay-ip |
TURN_RELAY_IP |
0.0.0.0 |
Local IPv4 address to bind relay sockets to. Separate from -listen-ip; see below. |
-port |
TURN_PORT |
3478 |
UDP and TCP signalling port. |
-tcp |
TURN_ENABLE_TCP |
true |
Also accept TURN over TCP, for clients on UDP-blocking networks. |
-tls-port |
TURN_TLS_PORT |
0 (off) |
TURNS port, usually 5349. Needs -tls-cert and -tls-key. |
-tls-cert / -tls-key |
TURN_TLS_CERT / TURN_TLS_KEY |
PEM chain and private key for -tls-port. |
|
-realm |
TURN_REALM |
first -matrix-domain |
TURN realm. Clients take this from the server, so it can be anything stable. |
-matrix-domain |
TURN_MATRIX_DOMAINS |
(empty) | Comma separated homeserver domains whose users may relay. Set this. |
-secret-file |
TURN_SECRET_FILE |
File holding the shared secret, instead of the environment variable. | |
-min-port / -max-port |
TURN_MIN_PORT / TURN_MAX_PORT |
49152 / 65535 |
Relay port range. Open exactly this range in the firewall. |
-max-allocations |
TURN_MAX_ALLOCATIONS |
128 |
Simultaneous relays one Matrix user may hold. 0 disables the limit. Needs to be generous; see below. |
-max-credential-ttl |
TURN_MAX_CREDENTIAL_TTL |
0 (off) |
Refuse credentials whose expiry is further out than this. |
-clock-skew |
TURN_CLOCK_SKEW |
5m |
Tolerance for clock drift against the homeserver. |
-allow-stun-binding |
TURN_ALLOW_STUN_BINDING |
false |
Answer unauthenticated STUN Binding requests. Off by default; see below. |
-allow-peer |
TURN_ALLOW_PEER |
CIDRs allowed as relay peers even though the built-in list denies them. | |
-deny-peer |
TURN_DENY_PEER |
Extra CIDRs to refuse as relay peers. | |
-log-level |
TURN_LOG_LEVEL |
info |
error, warn, info, debug, trace. |
-library-log-level |
TURN_LIBRARY_LOG_LEVEL |
warn |
Level for pion/turn's own internal messages, kept separate because they are chatty. |
-status-interval |
TURN_STATUS_INTERVAL |
0 (off) |
Emit a periodic line with the active allocation count. |
-gen-credential |
Print a valid credential pair for a user id and exit, for testing. |
The secret here must be byte-for-byte the one your homeserver uses. On startup the server prints a fingerprint of the secret it loaded so you can compare the two without printing either:
INFO shared secret loaded, fingerprint=sha256:4e60ed5f (compare with your homeserver's turn_secret)
For tuwunel / conduwuit / Conduit, in the config:
turn_uris = ["turn:turn.example.org:3478?transport=udp", "turn:turn.example.org:3478?transport=tcp"]
turn_secret = "the same secret"
turn_ttl = 86400turn_ttl must be longer than your longest call. A client fetches
credentials once when the call starts and keeps using them: TURN allocations,
permissions and channel bindings all have to be re-authenticated every few
minutes with that same credential. Once it expires the refresh is refused and
the call drops mid-conversation, which looks like this:
WARN auth REJECT src=198.51.100.7:62721 user="@alice:example.org" reason=credential expired at 2026-07-26T06:00:35Z (6m49s ago)
Shortening turn_ttl buys very little security — the credential is already
bound to one Matrix user, capped by -max-allocations, and unable to reach
private address space — so a short value mostly just cuts calls off. 86400
(24 hours) is Synapse's default and a good choice. -clock-skew adds a few
minutes of grace on top, and if you set -max-credential-ttl it must be at
least turn_ttl or the server will reject every credential.
For Synapse, in homeserver.yaml:
turn_uris: ["turn:turn.example.org:3478?transport=udp", "turn:turn.example.org:3478?transport=tcp"]
turn_shared_secret: "the same secret"
turn_user_lifetime: 86400000
turn_allow_guests: falseMigrating from coturn: static-auth-secret becomes $TURN_STATIC_AUTH_SECRET,
realm becomes -realm, min-port/max-port carry over unchanged, and
external-ip becomes -public-ip. The rest of turnserver.conf has no
equivalent because the behaviour it configured is either the default here or
not implemented.
Listen on both families by listing both addresses:
./mtxturn -public-ip 203.0.113.10 -listen-ip '0.0.0.0,::' -matrix-domain example.orgThat opens two independent sockets and says so at startup:
INFO listening for TURN over UDP on 0.0.0.0:3478 (IPv4)
INFO listening for TURN over UDP on [::]:3478 (IPv6 only)
A lone -listen-ip :: instead gives a single dual-stack socket, which on Linux
with the default net.ipv6.bindv6only=0 serves IPv4 clients too, reported as
(IPv4 and IPv6). Both arrangements work; listing both addresses is more
explicit and does not depend on that sysctl.
Relay sockets are always IPv4. pion allocates them as udp4 and does not
implement RFC 6156 REQUESTED-ADDRESS-FAMILY, so there is no way to hand out an
IPv6 relay candidate. -public-ip must therefore be an IPv4 address, and the
server refuses to start if it is not.
That is less limiting than it sounds, because the two legs of a relayed call are independent. The client↔server leg runs over whatever family the client has; the relay↔peer leg is IPv4. So an IPv6-only client reaches the server over IPv6, is handed an IPv4 relay candidate, and an IPv4-only peer on the far side talks to that candidate happily. Cross-family calls work — that is exactly what a relay is for.
The one case that does not work is two IPv6-only peers that both need a relay: neither can use an IPv4 relay candidate, and ICE only pairs candidates within a family. In practice those peers usually reach each other directly over IPv6, and genuinely IPv6-only clients are rare — most mobile networks that look IPv6-only provide IPv4 through 464XLAT.
For any of this to matter, the hostname in your turn_uris needs an AAAA
record as well as an A record, or IPv6 clients will never try the IPv6 socket.
3478/udp, 3478/tcp signalling, on IPv4 and IPv6 if you listen on both
49152-65535/udp relay range, IPv4 only, must match -min-port/-max-port
5349/tcp only if you enable -tls-port
Your homeserver hands each logged-in user a short-lived credential derived from the shared secret:
username = "<unix-expiry>:<@user:example.org>"
password = base64(HMAC-SHA1(secret, username))
A client proves possession of that password through STUN MESSAGE-INTEGRITY. It cannot invent a username, because the matching password requires the secret.
On top of that signature check, a request is refused unless:
- the username parses as
<expiry>:<matrix user id>; - the expiry has not passed (within
-clock-skew); - the expiry is not further out than
-max-credential-ttl, when set; - the user id belongs to a domain in
-matrix-domain; - the user is under their
-max-allocationslimit.
Then, for each peer the client asks to reach, the address must not be in private, loopback, link-local, multicast, CGNAT or reserved space. This is the check that stops a relay being used to reach your router's admin page or a cloud metadata endpoint, and it applies to authenticated users too.
Unauthenticated STUN Binding is the one thing a TURN server normally answers without a credential. Here it is disabled, along with anything that is not a well-formed STUN or ChannelData packet. Both are discarded on the UDP socket before the TURN state machine sees them, so a scanner gets no reply and produces no log line:
INFO STUN Binding is disabled: unauthenticated probes on UDP are dropped without a reply
Discarded traffic is still counted, so silence is not blindness. Turn on
-status-interval to see the totals:
INFO status active_allocations=1 tracked_users=1 ... dropped_non_stun=1482 dropped_stun_binding=93
-log-level debug adds one line per source address, deduplicated per minute,
saying what was dropped and why.
Generate a credential the way the homeserver would, then point any TURN client at the server:
export TURN_STATIC_AUTH_SECRET="..."
./mtxturn -realm example.org -gen-credential '@alice:example.org'The pair it prints works with turnutils_uclient, the
Trickle ICE page,
or Element's own call diagnostics. A working relay shows a candidate of type
relay in Trickle ICE.
Run the test suite with:
go test -race ./...It covers credential derivation against a vector from the reference Python implementation, every rejection path, the peer filter, the per-user quota, and a full allocation with real traffic pushed across the relay.
mtxturn.service in this directory is a starting point. It runs the binary
in the foreground as a dynamic user with no privileges, reads the secret from
an environment file, and lets systemd handle restarts and journald handle logs.
sudo install -m 0755 mtxturn /usr/local/bin/mtxturn
sudo install -m 0600 /dev/null /etc/mtxturn.env # then put the secret in it
sudo cp mtxturn.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now mtxturn
journalctl -u mtxturn -fNot implemented, because a Matrix homeserver does not need it: TCP relay allocations (RFC 6062), TURN over DTLS, ALPN, mobility (RFC 8016), a user database, quotas by bandwidth, or Prometheus metrics. If you need any of them, coturn is the right answer.