这篇记录 coturn TURN 服务器的 Docker Compose 部署方式。coturn 主要用于解决 Matrix 语音、视频通话中的 NAT 穿透问题,部署好之后可以在 Synapse 的 homeserver.yaml 中配置 turn_uris 和共享密钥。
前面 Synapse、Nginx Proxy Manager、ntfy 这类 HTTP 服务适合统一接入 matrix bridge 网络,反代容器可以直接通过容器名访问它们。但 coturn 不太一样:TURN 服务要直接处理 3478/udp 和一整段 UDP 中继端口。这里按小规模自用场景收窄为 50000-50200/udp,大约 200 个 UDP 中继端口;如果用 Docker bridge 网络再映射端口段,启动容器时仍然可能比 host 网络慢。
所以本文的 coturn 改用 network_mode: host:coturn 直接监听宿主机端口,不再加入 matrix Docker 网络。Synapse 仍然通过公网域名和共享密钥使用 coturn,不需要和 coturn 在同一个 Docker 网络里。
一、准备目录
先创建 coturn 的配置目录:
mkdir -p /root/data/docker_data/coturn/etc/coturn
cd /root/data/docker_data/coturn/etc/coturn创建基础配置文件:
touch turnserver.conf生成一个 68 位随机共享密钥,后面 turnserver.conf 和 Synapse 都要用同一个值:
cat /dev/urandom | tr -cd '[:alnum:]' | fold -w 68 | head -n 1把输出结果保存下来,下面用 你的68位随机密钥 代替。
二、关于证书和 TURNS
本文默认只启用普通 TURN,不启用 TURN TLS / TURNS,也不准备证书。原因很简单:Matrix 语音通话优先需要稳定的 3478/udp 和 UDP 中继端口段;如果要使用 turns:,客户端会校验证书链,证书配置反而会给这套自用部署增加额外变量。
所以这篇的配置会显式写 no-tls 和 no-dtls,让 coturn 不启动 TLS/DTLS listener,也避免它查找默认证书文件时打印证书警告。对应地,后面的 Synapse 配置也只写 turn:,不写 turns:。
三、创建 turnserver.conf
编辑 coturn 配置文件:
nvim /root/data/docker_data/coturn/etc/coturn/turnserver.conf写入下面的配置,并把域名和密钥换成你自己的:
# 日志级别:info、debug、verbose
# 注意:CoTurn 的 log-file 配置才决定级别,单写一个 info 是错的,这里保持注释即可
# 启用长期凭证与共享密钥认证
use-auth-secret
static-auth-secret=你的68位随机密钥
# 填入你的外网的域名,即使非标443端口,也不要添加其它的端口,只保留域名即可!
realm=coturn.example.com
# 添加明确的 IPv4 监听地址,防止出现 "NO EXPLICIT LISTENER ADDRESS" 警告。
# 如果公网 IPv6、AAAA 记录或 RouterOS IPv6 防火墙不确定,先不要启用 IPv6。
listening-ip=0.0.0.0
# listening-ip=::
# 如果 Docker 主机在 RouterOS、光猫或云 NAT 后面,必须写公网 IPv4 和本机内网 IPv4 的映射。
# 如果 Docker 主机自己直接持有公网 IPv4,就不要写这一行。
# external-ip=你的公网IPv4/你的Docker主机内网IPv4
# 不使用 turns: 时关闭 TLS/DTLS listener,避免 coturn 查找默认证书文件并打印证书警告
no-tls
no-dtls
# 把 pid 文件写到容器可写目录,避免 Cannot create pid file 警告
pidfile=/var/tmp/turnserver.pid
# 管理功能,可选
# cli-password=你的管理密码
# UDP 中继端口范围
min-port=50000
max-port=50200
# 禁止中继访问内网私有地址
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
如果公网 IPv4 是固定的,并且 Docker 主机在 NAT 后面,例如公网入口在 RouterOS 上,而 Docker 主机内网地址是 10.20.20.8,可以把 external-ip 改成类似下面这样:
external-ip=203.0.113.10/10.20.20.8
这会让 coturn 对外发出的 IPv4 relay candidate 使用公网 IPv4,而不是把内网地址交给公网客户端。公网 IPv4 会变化时,建议用 DDNS 脚本自动改写这一行并重启 coturn;在没有确认 IPv6 路径稳定前,不建议依赖 IPv6 兜底。
如果 Docker 主机本身有公网 IPv6,也可以取消 # listening-ip=:: 的注释,让 coturn 同时监听 IPv6。IPv6 不需要 external-ip,但域名的 AAAA 记录、防火墙和 RouterOS IPv6 转发规则必须跟上;如果 IPv6 地址会变化,需要确保 DDNS 能及时更新。只要其中任意一环不确定,就先保持 IPv4-only。
不要再加入 no-tlsv1、no-tlsv1_1、no-tlsv1_3 这类旧配置。较新的 coturn 镜像默认已经不启用 TLS 1.0/1.1,这些旧写法在新版本里可能会打印 Bad configuration format 警告。这个警告通常不是手机端无声的直接原因,但说明配置文件里混入了过时参数,建议删掉。
内网双栈且不启用证书的配置
如果 coturn 跑在内网 Docker 主机上,公网入口在 RouterOS、光猫或上级路由上,并且公网 IPv4 会变化,可以先不写 external-ip,改用 IPv4/IPv6 双栈监听。这个写法适合内网自用、IPv6 可用、暂时不启用 TURN TLS 证书的场景:
# 排障时可以临时打开 verbose,稳定后建议注释掉,避免日志太多
verbose
# 启用长期凭证与共享密钥认证
use-auth-secret
static-auth-secret=你的68位随机密钥
realm=coturn.example.com
# IPv4/IPv6 双栈监听
listening-ip=0.0.0.0
listening-ip=::
# 不使用 turns: 时关闭 TLS/DTLS listener,避免 coturn 查找默认证书文件并打印证书警告
no-tls
no-dtls
# 把 pid 文件写到容器可写目录,避免 Cannot create pid file 警告
pidfile=/var/tmp/turnserver.pid
# UDP 中继端口范围
min-port=50000
max-port=50200
# 内网双栈环境里,客户端可能会提交内网 IPv4 或 ULA IPv6 候选地址。
# 如果通话时日志出现大量 403 Forbidden IP,可以先不要启用这些黑名单。
#denied-peer-ip=10.0.0.0-10.255.255.255
#denied-peer-ip=192.168.0.0-192.168.255.255
#denied-peer-ip=172.16.0.0-172.31.255.255
这个配置里没有 external-ip,因此 IPv4 relay candidate 可能仍然出现内网地址。它更依赖 IPv6 直连、内网候选或客户端最终选到可用路径。公网 IPv4 固定时,仍然更推荐上一段的 external-ip=公网IPv4/内网IPv4 写法;公网 IPv4 会变化时,可以用 DDNS 脚本自动改写 external-ip 并重启 coturn,或者先采用双栈配置观察实际通话效果。
使用双栈配置时,域名的 AAAA 记录必须能及时更新到 Docker 主机当前公网 IPv6,并且 RouterOS IPv6 防火墙要放行 3478/tcp、3478/udp 和 50000-50200/udp。如果不配置证书,Synapse 中不要写 turns:,只写普通 turn:。
四、创建 docker-compose.yaml
新建 Compose 文件:
nvim /root/data/docker_data/coturn/docker-compose.yaml写入下面的配置:
services:
coturn:
image: coturn/coturn:latest
container_name: coturn
restart: unless-stopped
# 这里不要再写 ports,也不要把 coturn 接入 matrix 网络。
# network_mode: host 会让 coturn 直接使用宿主机网络栈。
# 容器内监听的 3478 和 50000-50200/udp 就是宿主机上的端口。
# 需要在宿主机防火墙/云安全组/路由器里放行:
# - 3478/tcp
# - 3478/udp
# - 50000-50200/udp
network_mode: host
command:
- "-c"
- "/etc/coturn/turnserver.conf"
- "--log-file=stdout"
volumes:
- /root/data/docker_data/coturn/etc/coturn/turnserver.conf:/etc/coturn/turnserver.conf
- /root/data/docker_data/coturn/etc/:/etc/这里不要再写 ports,也不要把 coturn 接入 matrix 网络。network_mode: host 会让 coturn 直接使用宿主机网络栈,容器内监听的 3478 和 50000-50200/udp 就是宿主机上的端口。command 明确指定读取 /etc/coturn/turnserver.conf,避免镜像默认命令自动探测外网 IP 导致启动卡住。
这和前面 Synapse、ntfy 的文章并不冲突:Synapse、ntfy、NPM 仍然适合留在 matrix 网络里;coturn 属于对外 TURN 边界服务,直接绑定宿主机端口更稳定,也能避开 UDP 端口段映射导致的启动卡顿。
五、启动 coturn
先前台启动观察日志:
cd /root/data/docker_data/coturn
docker compose up这时终端会停在前台持续打印日志,看起来像“卡住了”,这是正常现象。先不要关闭这个终端,观察日志里是否有明显报错。
如果想确认它到底是“活着”还是“挂了”,保持当前这个前台终端不要动,打开一个新的终端窗口,进入同一个目录后检查容器状态:
cd /root/data/docker_data/coturn
docker compose ps也可以在新终端里查看实时日志:
docker logs -f coturn如果前台终端里按 Ctrl+C 后一直卡在类似 Stopping 73.2s 的状态无法结束,可以在新的终端窗口强制删除这个卡住的容器:
docker rm -f coturn如果你之前用过 bridge 网络版本,并且 Compose 里还保留了下面这种端口段映射:
- '50000-50200:50000-50200/udp'启动和停止都可能很慢。建议先改成本文的 network_mode: host,再重新启动。
可以先用下面的命令确认最终 Compose 配置里已经没有 ports,并且网络模式确实是 host:
docker compose config如果你使用的是旧命令,也可以执行:
docker-compose config启动后再检查容器网络模式:
docker inspect coturn --format '{{.HostConfig.NetworkMode}}'正常应输出:
host也可以检查端口监听:
ss -tulnp | grep 3478如果输出里看到的是 docker-proxy,说明当前容器仍然在走 Docker 端口映射,通常是 Compose 文件里还残留了 ports,或者存在 docker-compose.override.yml 之类的覆盖配置。先删掉旧容器,再重新创建:
docker rm -f coturn
docker compose up -d --force-recreate确认配置没有明显报错后,再改成后台运行:
docker compose up -d后台启动后再次查看容器状态:
docker compose ps查看实时日志:
docker logs -f coturn六、开放防火墙端口
服务器防火墙、云安全组或路由器端口转发需要放行:
| 端口 | 协议 | 用途 |
|---|---|---|
3478 |
TCP/UDP | TURN 普通监听端口 |
50000-50200 |
UDP | TURN 中继端口范围 |
5349只在启用 TURN TLS / TURNS 并配置好可信域名证书时才需要。本文默认使用no-tls、no-dtls不启动证书,所以不用开放5349,Synapse 里也不要写turns:。
其中 3478/udp 很重要,WebRTC 通话优先走 UDP,保留它能减少延迟并提高连接成功率。3478/tcp 可以作为普通 TURN over TCP 的补充入口。
如果你的服务器开启了 IPv6,也要同步检查 IPv6 防火墙规则。
如果宿主机使用 ufw,可以参考下面的命令放行:
ufw allow 3478/tcp
ufw allow 3478/udp
ufw allow 50000:50200/udp如果前面还有 RouterOS 软路由,公网入口在软路由上,而 Docker 主机内网 IP 是 10.20.20.8,就应该在 RouterOS 上把 coturn 端口转发到这台 Docker 主机。示例命令如下:
/ip firewall nat
add chain=dstnat action=dst-nat dst-address-type=local protocol=tcp dst-port=3478 to-addresses=10.20.20.8 to-ports=3478 comment="coturn 3478 tcp"
add chain=dstnat action=dst-nat dst-address-type=local protocol=udp dst-port=3478 to-addresses=10.20.20.8 to-ports=3478 comment="coturn 3478 udp"
add chain=dstnat action=dst-nat dst-address-type=local protocol=udp dst-port=50000-50200 to-addresses=10.20.20.8 comment="coturn relay udp"
如果你的 RouterOS 防火墙默认丢弃转发流量,还需要允许已经做了 dstnat 的转发连接:
/ip firewall filter
add chain=forward action=accept connection-nat-state=dstnat comment="allow dstnat forwarded services"
IPv6 不需要做 dstnat。如果 Docker 主机已经拿到了公网 IPv6 地址,就在 RouterOS 的 IPv6 防火墙里允许转发这些端口。按 WinBox 里的写法,对应就是 chain=forward、protocol、dst-port,动作选择 accept:
/ipv6 firewall filter
add chain=forward action=accept protocol=tcp dst-port=3478 comment="allow coturn 3478 tcp"
add chain=forward action=accept protocol=udp dst-port=3478 comment="allow coturn 3478 udp"
add chain=forward action=accept protocol=udp dst-port=50000-50200 comment="allow coturn relay udp"
这里不加 in-interface-list=WAN,是为了让内网设备访问公网域名时也能匹配到规则。如果你的内网里有多台 IPv6 主机也可能使用这些端口,可以再加 dst-address=你的Docker主机IPv6地址 精确限定目标。只有明确不需要内网设备通过公网域名访问时,才建议额外限制 in-interface-list=WAN。无论哪种写法,这些 IPv6 accept 规则都要放在最终丢弃转发流量的规则之前,否则规则写了也不会生效。
如果想更准确地观察一次通话到底有没有走这些规则,可以在通话前先清零 coturn 相关规则计数:
/ip firewall nat reset-counters [find comment~"coturn"]
/ip firewall filter reset-counters [find comment~"coturn"]
/ipv6 firewall filter reset-counters [find comment~"coturn"]
然后发起通话,保持 30 秒左右,再查看计数:
/ip firewall nat print stats where comment~"coturn"
/ip firewall filter print stats where comment~"coturn"
/ipv6 firewall filter print stats where comment~"coturn"
需要注意,WebRTC 会优先选择能直连的 ICE 候选。通话能听到但 50000-50200/udp 计数没有明显增长,并不一定是错误,可能是这次通话走了 IPv6 直连、局域网直连或其他可用路径。只有当通话实际失败,且 coturn 日志里 peer usage 长期为 0,才需要重点回头检查 relay UDP 端口段。
七、Nginx Proxy Manager 配置
coturn 不是普通 Web 服务,不能在 Nginx Proxy Manager(NPM)里用 Proxy Hosts 按 HTTP/HTTPS 网站那样反向代理。TURN 需要 TCP/UDP 端口和 UDP 中继端口段,尤其 3478/udp 和 50000-50200/udp 对 WebRTC 通话很关键。
推荐做法是:DNS 直接指向运行 coturn 的服务器,服务器防火墙直接放行 coturn 端口,NPM 不参与 TURN 流量转发。
DNS 示例:
coturn.example.com A 服务器IPv4
coturn.example.com AAAA 服务器IPv6本文使用 network_mode: host 后,coturn 不在 matrix bridge 网络里,NPM 后台也不能再把 Stream 目标写成 coturn 这个容器名。即使强行用 NPM Streams 转发 3478,UDP 中继端口段 50000-50200/udp 仍然不适合通过 NPM 维护。
因此更推荐的最终结构是:
- Synapse、ntfy、NPM 等 HTTP/HTTPS 服务继续接入
matrixDocker 网络。 - NPM 继续负责 Synapse、ntfy 等 Web 服务的反代。
- coturn 使用
network_mode: host,直接监听宿主机的3478/tcp、3478/udp和50000-50200/udp。 - 固定公网 IPv4 场景下,
coturn.example.com的A记录直接指向公网 IPv4,并在 coturn 中配置external-ip=公网IPv4/内网IPv4。 - 公网 IPv4 会变化或主要依赖 IPv6 的内网双栈场景下,保持域名的
AAAA记录及时更新,coturn 可以不写external-ip,但要确认 IPv6 防火墙放行了 TURN 端口。 - Synapse 只需要在
homeserver.yaml中填写 coturn 的公网域名和同一个共享密钥。
八、接入 Synapse
编辑 Synapse 的配置文件:
nvim /root/data/docker_data/synapse/data/homeserver.yaml加入 TURN 配置,把域名和密钥改成自己的:
# 旧版 TURN 配置,保留给 Element Web/Desktop 和旧客户端使用
turn_uris:
- "turn:coturn.example.com?transport=udp"
- "turn:coturn.example.com?transport=tcp"
turn_shared_secret: "你的68位随机密钥"
turn_user_lifetime: 86400000
turn_allow_guests: true这里的 turn_shared_secret 必须和 turnserver.conf 里的 static-auth-secret 完全一致。coturn.example.com 只是示例域名,实际配置时换成自己的 coturn 域名,不要带端口。本文没有启用证书,所以不要把 turn: 写成 turns:。
修改完成后重启 Synapse:
cd /root/data/docker_data/synapse
docker compose restart synapse九、常用排查命令
查看 coturn 日志:
docker logs -f coturn查看宿主机监听端口:
netstat -anpl | grep turnserver如果没有 netstat,可以先安装:
apt install -y net-tools如果你想确认 coturn 是否真的用了 host 网络,可以查看容器网络模式:
docker inspect coturn --format '{{.HostConfig.NetworkMode}}'正常应输出:
hostSynapse 容器仍然可以继续留在 matrix 网络里。coturn 不需要和 matrix-synapse 处在同一个 Docker 网络,只要 Synapse 配置里的 turn_uris 能解析到 coturn 的公网域名即可。
认证错误:Cannot find credentials of user
如果 coturn 日志里出现类似下面的报错:
check_stun_auth: Cannot find credentials of user <testuser>先看你是不是用 Trickle ICE 或其他 WebRTC 测试页直接填了 testuser 和共享密钥。本文使用的是 use-auth-secret + static-auth-secret,也就是 Matrix/Synapse 常用的 TURN REST API 动态凭据模式;客户端用户名不是固定的 testuser,密码也不是共享密钥本身,而是 Synapse 按共享密钥临时生成的一组用户名和密码。
测试 shared secret 模式时,可以在服务器上用下面的命令临时生成一组 1 小时有效的 TURN 用户名和密码:
secret='你的68位随机密钥'
u="$(($(date +%s) + 3600)):test"
p="$(printf '%s' "$u" | openssl dgst -sha1 -hmac "$secret" -binary | base64)"
printf 'username: %s\npassword: %s\n' "$u" "$p"然后在 Trickle ICE 里填写:
URI: turn:coturn.example.com?transport=udp
username: 上面输出的 username
password: 上面输出的 password正常情况下结果里应该至少出现一条 relay candidate。只出现 host 或 srflx,或者继续报 Cannot find credentials,就优先检查下面几项:
turnserver.conf里的static-auth-secret和 Synapsehomeserver.yaml里的turn_shared_secret必须完全一致。- 修改
homeserver.yaml后要重启 Synapse;修改turnserver.conf后要重启 coturn。 turnserver.conf里不要同时混用use-auth-secret和固定用户user=testuser:password。如果要临时测试固定用户名,必须先注释掉use-auth-secret和static-auth-secret,改用lt-cred-mech与user=用户名:密码,测试完再改回 shared secret 模式。- 如果 Docker 主机在 NAT 后面,确认已经配置
external-ip=公网IPv4/内网IPv4,并且路由器、云安全组和宿主机防火墙都放行了3478/udp与50000-50200/udp。