2026 年 coturn TURN 服务器 Docker Compose 部署:配合 Matrix Synapse 使用

记录 coturn TURN 服务器的 Docker Compose 部署方式:生成共享密钥,配置 turnserver.conf,使用 host 网络开放 TURN 端口,并接入 Synapse。

这篇记录 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-tlsno-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-tlsv1no-tlsv1_1no-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/tcp3478/udp50000-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 直接使用宿主机网络栈,容器内监听的 347850000-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-tlsno-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=forwardprotocoldst-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/udp50000-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 服务继续接入 matrix Docker 网络。
  • NPM 继续负责 Synapse、ntfy 等 Web 服务的反代。
  • coturn 使用 network_mode: host,直接监听宿主机的 3478/tcp3478/udp50000-50200/udp
  • 固定公网 IPv4 场景下,coturn.example.comA 记录直接指向公网 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}}'

正常应输出:

host

Synapse 容器仍然可以继续留在 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。只出现 hostsrflx,或者继续报 Cannot find credentials,就优先检查下面几项:

  • turnserver.conf 里的 static-auth-secret 和 Synapse homeserver.yaml 里的 turn_shared_secret 必须完全一致。
  • 修改 homeserver.yaml 后要重启 Synapse;修改 turnserver.conf 后要重启 coturn。
  • turnserver.conf 里不要同时混用 use-auth-secret 和固定用户 user=testuser:password。如果要临时测试固定用户名,必须先注释掉 use-auth-secretstatic-auth-secret,改用 lt-cred-mechuser=用户名:密码,测试完再改回 shared secret 模式。
  • 如果 Docker 主机在 NAT 后面,确认已经配置 external-ip=公网IPv4/内网IPv4,并且路由器、云安全组和宿主机防火墙都放行了 3478/udp50000-50200/udp