自建 Tailscale DERP 中继节点(Docker Compose)

2025年8月8日| 更新于 2026年8月30日| Ruichen Zhou| 约 39 分钟阅读

跨德国和中国做远程开发时,Tailscale 有时会长期走公共 DERP 中继,延迟高而且不稳定。要改善这种链路体验,第一步往往不是调 SSH,而是自己补一台更靠近实际网络路径的 DERP 节点。

这篇文章是给未来的自己看的,也给需要自建 Tailscale DERP 的人做一个可复制的参考:在一台小型 VPS 上,用 Docker Compose 跑一个 derper 容器,支持:

  • Let’s Encrypt 自动签证书(80/443 开放时)
  • 或手动证书(80 被机房拦截时)
  • 通过 Tailscale Admin Console 或 Headscale 把自建 Region 下发给客户端(单节点或多节点)

如果还没有自己的 Tailnet,可以先看用 Docker Compose 自建 Headscale。Headscale 负责设备注册和配置下发,DERP 只在设备无法直连时转发加密流量。

2026-08-03 更新:原配置使用第三方 latest 镜像,并只挂载单个 tailscaled.sock 文件。tailscaled 重启重建 runtime 目录后会留下 stale bind mount,导致 --verify-clients 拒绝客户端,而 netcheck 的 HTTPS 探测可能仍显示正常。本文已改为同 revision 自行构建镜像、挂载整个 runtime 目录、由 systemd 让容器随 tailscaled 重建(见第 4、5 节;已在两台 VPS 上验证)。

2026-08-30 更新:新增 6.3:某个官方 DERP Region 故障时,Headscale 可以只排除该 Region,不必关闭全部官方 DERP。


0. 适用环境与整体思路

系统要求

  • Ubuntu 22.04+ 或 Debian 12+
  • 1C / 1G 也能跑(低负载场景足够)

依赖条件

  • 一台有公网 IPv4 的 VPS(建议开放 80/TCP、443/TCP、3478/UDP)
  • 一个域名,例如:derp.example.com,A 记录指向该 VPS
  • 一个已有的 Tailnet,可以使用 Tailscale 官方控制服务器或自建 Headscale
  • root 或可 sudo 的账号

推荐方案

  • 优先使用:Docker Compose + letsencrypt 自动证书
  • 若 80/TCP 无法使用,再切换到 manual + 自备证书

如果要跑多个 DERP 节点,建议为每台机器准备独立子域名,例如:

  • derp.example.com → Region hk1
  • derphk.example.com → Region hk2

1. 安装 Docker(含 Compose)

发行版仓库的 docker.io 与 Docker 官方仓库的 docker-compose-plugin 不要混用,这里统一使用 Docker 官方 apt 仓库。完整说明见官方文档:Ubuntu 安装 Docker EngineDebian 安装 Docker Engine

先移除发行版自带的旧包(没装过的包会提示找不到,不影响后续步骤):

for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
  sudo apt-get remove -y $pkg
done

添加 Docker 官方 apt 仓库(Debian 系统把下面两处 ubuntu 替换成 debian):

sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

安装 Docker Engine 和 Compose 插件:

sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

docker-ce 安装后默认已配置开机自启。验证:

docker --version
docker compose version

2. 安装并登录 Tailscale(宿主机)

derper 如果启用 --verify-clients,需要访问宿主机的 tailscaled.sock,因此宿主机必须运行 tailscaled 并加入你的 Tailnet。Tailscale 的官方说明还有一个容易漏掉的硬约束:开启客户端验证时,derper 与同机 tailscaled 必须从同一个 Git revision 构建。

curl -fsSL https://tailscale.com/install.sh | sh
sudo systemctl enable --now tailscaled
sudo tailscale up --ssh=false    # 按提示在浏览器登录

检查状态:

systemctl is-active tailscaled
tailscale version
tailscale status | head -n 5

记下 tailscale version 输出中的 tailscale commit。后面的 TAILSCALE_REF 必须使用这个完整 commit,而不是只写一个看起来相同的版本号。

2.1 关闭 relay VPS 的自动安装更新

专门承担 DERP 的 VPS 不应让 tailscaled 单独自动升级。否则宿主机先换到新 revision,容器里的 derper 仍是旧 revision,即使 systemd 正确重建了容器,--verify-clients 的版本约束仍然被破坏。

只在这些 relay VPS 上关闭自动安装:

sudo tailscale set --auto-update=false

这不会关闭更新检查。可以用下面的输出确认配置:

tailscale debug prefs | grep -A3 '"AutoUpdate"'

预期为:

"AutoUpdate": {
  "Check": true,
  "Apply": false
}

Check: true 仍会提示存在新版本,Apply: false 只是不再自动安装。不要把这条设置扩大到所有普通 Tailscale 客户端;这里只是因为 DERP 与同机 tailscaled 必须成对升级。


3. 防火墙与安全组端口放行

需要同时在云厂商“安全组”和系统防火墙(如 UFW)中放行:

  • 80/TCP(Let’s Encrypt HTTP-01;如果后面只用 manual,可不开放)
  • 443/TCP(DERP TLS)
  • 3478/UDP(STUN)

示例(UFW):

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 3478/udp
sudo ufw enable

4. 固定 revision 构建镜像

创建持久化目录和 compose 目录:

# 身份密钥 & ACME 缓存
sudo mkdir -p /opt/derper-data

# compose 项目
sudo mkdir -p /opt/derper && cd /opt/derper

新建 /opt/derper/Dockerfile。下面的 Go 基础镜像是 2026-08-03 验证时使用的版本;如果将来 Tailscale 提高最低 Go 版本,应随所选 revision 一起更新:

FROM golang:1.26.5-bookworm AS builder

ARG TAILSCALE_REF
RUN test -n "$TAILSCALE_REF"
RUN CGO_ENABLED=0 go install tailscale.com/cmd/derper@"$TAILSCALE_REF" \
    && CGO_ENABLED=0 go install tailscale.com/cmd/derpprobe@"$TAILSCALE_REF" \
    && CGO_ENABLED=0 go install tailscale.com/cmd/stunc@"$TAILSCALE_REF"

FROM debian:bookworm-slim

ARG TAILSCALE_REF
LABEL org.opencontainers.image.source="https://github.com/tailscale/tailscale" \
      org.opencontainers.image.revision="$TAILSCALE_REF"

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /go/bin/derper /usr/local/bin/derper
COPY --from=builder /go/bin/derpprobe /usr/local/bin/derpprobe
COPY --from=builder /go/bin/stunc /usr/local/bin/stunc

ENTRYPOINT ["/usr/local/bin/derper"]

再新建 /opt/derper/docker-compose.yml。替换域名,并把 TAILSCALE_REF 替换成前一步记录的完整 commit:

services:
  derper:
    image: local/derper:tailscale-matched
    build:
      context: .
      args:
        TAILSCALE_REF: "<tailscaled-git-commit>"
    container_name: derper
    restart: unless-stopped
    ports:
      - "80:80/tcp"        # Let's Encrypt HTTP-01
      - "443:443/tcp"      # DERP TLS
      - "3478:3478/udp"    # STUN
    command:
      - "--hostname=derp.example.com"
      - "--certmode=letsencrypt"
      - "--certdir=/var/lib/derper/certs"
      - "--a=:443"
      - "--http-port=80"
      - "--stun=true"
      - "--stun-port=3478"
      - "--verify-clients=true"
      - "--socket=/var/run/tailscale/tailscaled.sock"
      - "--c=/var/lib/derper/derper.key"
    security_opt:
      - "no-new-privileges:true"
    volumes:
      - /opt/derper-data:/var/lib/derper
      - /var/run/tailscale:/var/run/tailscale:ro

/var/lib/derper 需要持久化,里面包含 derper.key、ACME 缓存和证书。这样容器重建不会频繁重新签证书,也能保持 DERP 身份稳定。

socket 则挂载整个 /var/run/tailscale 目录,而不是单独挂载 socket 文件;ro 保证容器不能修改宿主机 runtime 目录。

先构建镜像,不要在 revision 不匹配时直接启动:

cd /opt/derper
docker compose build --pull derper
docker image inspect local/derper:tailscale-matched \
  --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}'

5. 让容器跟随 tailscaled 重建

只写 restart: unless-stopped 不够。它能在容器退出时重启容器,但 tailscaled 单独重启时,DERP 容器不会退出,旧 bind mount 仍可能留在原 inode。

新建 /etc/systemd/system/derper-compose.service

[Unit]
Description=DERP container synchronized with tailscaled runtime
Requires=docker.service tailscaled.service
After=docker.service tailscaled.service network-online.target
Wants=network-online.target
PartOf=tailscaled.service

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/derper
ExecStart=/usr/bin/docker compose up -d --force-recreate --no-build derper
ExecStartPost=/usr/bin/docker exec derper test -S /var/run/tailscale/tailscaled.sock
ExecStop=/usr/bin/docker compose stop derper
TimeoutStartSec=120
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target

PartOf=tailscaled.service 会把对 tailscaled 的 stop/restart 传播给这个 unit;--force-recreate 让 Docker 重新建立 bind mount;ExecStartPost 则在容器看不到 socket 时直接让启动失败,而不是留下一个表面运行、实际拒绝客户端的 DERP。

启用服务:

sudo systemctl daemon-reload
sudo systemctl enable --now derper-compose.service

5.1 基础校验

检查端口监听情况:

# 80 / 443
ss -lntup | grep -E ':80|:443'
# 3478
ss -lnup | grep -E ':3478'

查看日志:

docker compose logs --tail=120
# 预期能看到类似 “serving on :443 with TLS” 的输出

docker exec derper test -S /var/run/tailscale/tailscaled.sock
systemctl is-active derper-compose.service

验证 TLS 证书是否正常(Issuer 是否为 Let’s Encrypt,CN/SAN 是否包含你的域名):

openssl s_client -connect derp.example.com:443 -servername derp.example.com -brief </dev/null

以上只能说明进程、端口、TLS 和 socket 基本正常。真正的 DERP 协议还要在已加入同一 tailnet 的客户端上测试:

tailscale debug derp 901

901 换成自己的 Region ID。预期结果应包含成功建立 DERP 连接,而不只是 HTTPS latency 成功。

5.2 成对升级 Tailscale 与 derper

第 5 节的 unit 解决的是 daemon 重启后 runtime socket inode 变化,不会自动下载源码、构建新版 derper。因此“可以自由重启”不等于“可以单独升级 Tailscale”。

双 DERP 节点应逐台升级。维护其中一台时,另一台和默认 DERP 仍可提供中继。每台按以下顺序执行:

  1. 停止这台机器的 DERP unit,但保持 unit enabled:

    sudo systemctl stop derper-compose.service
  2. 用正常的软件包或 Tailscale CLI 更新宿主机客户端:

    sudo tailscale update
    tailscale version
  3. 复制新的完整 tailscale commit 到 Compose 的 TAILSCALE_REF。如果 tailscale version 显示的 Go 版本高于 Dockerfile 基础镜像,也同步更新 Go 镜像版本。

  4. 先构建和核对 image label,再恢复服务:

    cd /opt/derper
    docker compose build --pull derper
    docker image inspect local/derper:tailscale-matched \
      --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}'
    sudo systemctl start derper-compose.service
  5. 在 VPS 上检查 socket 和日志,再从另一台 tailnet 客户端测试完整 DERP 协议:

    # 在 VPS 上
    docker exec derper test -S /var/run/tailscale/tailscaled.sock
    docker compose logs --tail=120
    
    # 在另一台 tailnet 客户端上
    tailscale debug derp <RegionID>

确认第一台正常后再升级第二台。安全更新也应及时按这个流程完成,而不是因为关闭自动安装就长期停留在旧版本。


6. 把自建 DERP 下发给客户端

使用 Tailscale 官方控制服务器时,在 Admin Console 中配置 derpMap;使用 Headscale 时,在 config.yaml 中加载本地 DERP Map。下面 6.1 和 6.2 是官方控制台的写法。Headscale 用户在 derp.yaml 中配置自建 Region 的方式见文末 Headscale 文档链接;6.3 单独讲 Headscale 下排除故障官方 Region 的做法。

进入 Admin Console → Access Controls,在 ACL JSON 中添加 derpMap 字段。

控制台支持带注释 JSON(jsonc),如果提示语法错误,可以先去掉注释再保存。

6.1 单节点示例

"derpMap": {
  "OmitDefaultRegions": false,
  "Regions": {
    "901": {
      "RegionID": 901,
      "RegionCode": "hk1",
      "RegionName": "derp_1c1g",
      "Nodes": [
        {
          "Name": "1",
          "RegionID": 901,
          "HostName": "derp.example.com"
        }
      ]
    }
  }
}

6.2 双节点示例(新旧两台 VPS)

"derpMap": {
  "OmitDefaultRegions": false,
  "Regions": {
    "901": {
      "RegionID": 901,
      "RegionCode": "hk1",
      "RegionName": "derp_1c1g",
      "Nodes": [
        { "Name": "1", "RegionID": 901, "HostName": "derp.example.com" }
      ]
    },
    "902": {
      "RegionID": 902,
      "RegionCode": "hk2",
      "RegionName": "derp_2c4g",
      "Nodes": [
        { "Name": "1", "RegionID": 902, "HostName": "derphk.example.com" }
      ]
    }
  }
}

保存之后,一般 1–3 分钟内会下发到客户端。

客户端验证(任意一个节点):

tailscale netcheck
# 预期能看到 hk1 / hk2 的 Region,且有延迟(UDP=true 更好)

多台 VPS 共用同一份 Dockerfile 和 compose 模板,但每台必须分别把 TAILSCALE_REF 改为本机 tailscale version 输出的 commit,不能只改 --hostname。连通性可用 tailscale ping <任意节点的 100.x IP> 验证。

6.3 Headscale:只排除一个故障的官方 Region

Headscale 可以同时加载 Tailscale 默认 DERP Map、本地自建 Region 和内置 DERP。遇到客户端反复报告某个官方 Region 不可用时,先确认问题是否只发生在这个 Region(以下命令需要 jq;没有的话直接看 tailscale debug derp-map 原始输出):

tailscale debug derp-map | jq '.Regions["20"]'
tailscale debug derp 20

第一条命令列出 Region 20 的实际节点,第二条测试完整 DERP 协议。还要分别检查这些节点的 IPv4、IPv6 和 TLS;443/TCP 能建立连接,只能证明端口可达,不能证明 TLS 和 DERP 数据通道可用。

如果其他官方 Region 和自建 Region 均正常,可以在 Headscale 加载的 derp.yaml 中只排除这个 Region。按Headscale 部署文章的目录结构,先创建宿主机文件 /opt/headscale/headscale/derp.yaml

regions:
  # Disabled 2026-08-30: TCP/443 was reachable, but TLS was reset on every
  # Region 20 node over IPv4 and IPv6; custom regions remained reachable.
  20: null

Headscale 容器默认只挂载了 config.yamldata 目录,容器内不存在 /etc/headscale/derp.yaml。在 /opt/headscale/compose.yamlheadscale 服务 volumes 中追加一条只读挂载:

    volumes:
      - ./headscale/config.yaml:/etc/headscale/config.yaml:ro
      - ./headscale/derp.yaml:/etc/headscale/derp.yaml:ro
      - ./headscale/data:/var/lib/headscale

config.yaml 仍然保留默认 DERP Map,并通过容器内路径加载本地文件:

derp:
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  paths:
    - /etc/headscale/derp.yaml

重建容器让新挂载生效:

cd /opt/headscale
sudo docker compose up -d

然后从客户端确认 Region 20 已消失,而其他官方 Region 和自建 Region 仍在:

tailscale debug derp-map | jq -r \
  '(.Regions | has("20")), (.Regions | keys | join(","))'
tailscale debug derp <自建RegionID>

不要把 urls 一并删除。那样会移除全部官方 DERP,回退流量只能全部走自己的服务器;自建节点停机或客户端到自建节点的路径异常时,会少一层可用的回退。

外置 derper 和 Headscale 内置 DERP 可能部署在同一台 VPS,例如外置 Region 901 与内置 Region 999。它们由不同服务提供,可以使用不同域名、证书和 STUN 端口,因此不是重复配置;但它们共享同一台主机和同一条公网链路,也不能算跨主机冗余。维护期间可以同时保留,不能因为两个 Region 都在线就认为这台 VPS 已经没有单点问题。

Headscale 官方文档也使用 regions: <RegionID>: null 排除单个 Region,并建议在移除默认 DERP 前先验证自建 DERP 的连通性。具体配置见 Customize DERP map


7. 无法使用 80/TCP 时:切换到 manual 证书模式

部分机房对 80/TCP 有限制,导致 Let’s Encrypt HTTP-01 失败。 这种情况下可以切换到 manual 模式,自行管理证书。

7.1 准备证书

准备好域名证书和私钥(CN/SAN 包含 derp.example.com):

  • derp.example.com.crt
  • derp.example.com.key

放到宿主机 /usr/local/cert

sudo mkdir -p /usr/local/cert
sudo cp derp.example.com.crt derp.example.com.key /usr/local/cert/
sudo chmod 700 /usr/local/cert
sudo chmod 600 /usr/local/cert/*

7.2 修改 Docker Compose

docker-compose.yml 中:

command:
  - "--hostname=derp.example.com"
  - "--certmode=manual"
  - "--certdir=/cert"
  - "--a=:443"
  - "--http-port=80"
  - "--stun=true"
  - "--stun-port=3478"
  - "--verify-clients=true"
  - "--socket=/var/run/tailscale/tailscaled.sock"
  - "--c=/var/lib/derper/derper.key"
volumes:
  - /opt/derper-data:/var/lib/derper
  - /usr/local/cert:/cert:ro
  - /var/run/tailscale:/var/run/tailscale:ro

这时可以不开放 80/TCP,但 443/TCP 和 3478/UDP 必须可达

重启并再次验证:

sudo systemctl restart derper-compose.service
docker compose logs --tail=120
openssl s_client -connect derp.example.com:443 -servername derp.example.com -brief </dev/null

8. 常见问题与排查思路

1)443 端口占用

日志中出现 bind: address already in use

ss -lntup | grep -E ':443'
# 找到占用进程,停止后再启动 derper 容器

2)tailscale netcheck 看不到自建 Region,或 Region 延迟为空

  • 检查 Admin Console 中 derpMap 的 JSON 是否保存成功、无语法错误;
  • 等待策略下发,并确认客户端网络能访问 DERP 域名的 443 端口;
  • 延迟为空只表示这次 latency probe 没测出来,不能单独证明 DERP 离线;
  • tailscale debug derp <RegionID> 测完整 DERP 协议,再结合服务端日志判断。

3)UDP: false

  • 说明客户端所在网络的 UDP 打洞能力较差;
  • DERP 仍然可以工作,只是部分连接会走 TCP;
  • 如果能控制网络环境,尽量保证客户端网络出站UDP可用。

4)Let’s Encrypt 申请失败

在日志中看到 acme/autocert 相关错误,或关于 HostWhitelist 的警告时:

  • 检查域名解析是否正确指向当前 VPS;
  • 检查 80/TCP 是否可从外网访问;
  • 确认 --hostname 与访问时的域名 / SNI 一致;
  • 如果机房不放行 80/TCP,直接切 manual 模式即可。

5)--verify-clients 报 socket 不存在或连接失败

先分别检查宿主机和容器看到的 runtime 目录:

stat -Lc '%d:%i %n' /var/run/tailscale /var/run/tailscale/tailscaled.sock
docker exec derper stat -Lc '%d:%i %n' \
  /var/run/tailscale /var/run/tailscale/tailscaled.sock

如果宿主机 socket 存在、容器内不存在,或容器 mountinfo 出现 //deleted,这是 stale bind mount,不是权限问题。立即恢复可用:

cd /opt/derper
docker compose up -d --force-recreate --no-build derper

持久化修复仍是第 5 节的 systemd unit。不要用 chmod 666 掩盖问题;它既不能换掉旧 inode,又让所有本机用户都能访问 socket。只有日志明确是 permission denied 时,才应检查容器 UID/GID 或组权限。

还要核对镜像 label 中的 revision 与 tailscale version 输出一致;版本号接近但 revision 不同,仍不满足官方对 --verify-clients 的要求。


6)频繁重建容器触发 LE 频率限制

  • 确保 /var/lib/derper 已挂载到宿主机: - /opt/derper-data:/var/lib/derper
  • 尽量不要在测试阶段频繁清空该目录。

9. Peer Relay 与 DERP 的分工

Tailscale 1.86 以后可以把 tailnet 内的设备配置成 Peer Relay。连接顺序是:直连、Peer Relay、DERP。Peer Relay 适合无法直连、又需要较低延迟或较高吞吐的固定设备;Peer Relay 不可用时,连接仍可使用 DERP。

在有公网 UDP 入口的 VPS 上启用:

sudo tailscale set --relay-server-port=40000
sudo ufw allow 40000/udp

然后在 tailnet policy 中授权。src 是需要通过 Peer Relay 被访问的固定设备,dst 是充当 Peer Relay 的 VPS;公开模板建议使用精确 tag,不要默认放大到整个 tailnet:

{
  "src": ["tag:china-fixed"],
  "dst": ["tag:peer-relay"],
  "app": {
    "tailscale.com/cap/relay": []
  }
}

所有相关节点都要使用 Tailscale 1.86 或更高版本。产生实际流量后检查:

tailscale ping <目标设>
tailscale status | grep peer-relay

直连随后建立、输出保持 direct 是正常结果,说明 Peer Relay 只是回退路径;只有直连失败且状态明确显示 peer-relay,才算验证了这条回退路径。


10. 迁移与备份

10.1 备份身份与 ACME 缓存

sudo tar -C /opt -czf derper-data-backup.tgz derper-data

10.2 迁移到新机器

  1. 在新机器上按前文步骤安装 Docker、Tailscale、防火墙规则;
  2. 将旧机器的 /opt/derper-data 拷贝到新机器同一路径;
  3. 把域名 A 记录切换到新 IP;
  4. 按前文步骤启动 compose 并验证。

11. 权限与安全建议

  • 持久化目录中包含私钥和证书,建议最小权限:

    sudo chown -R root:root /opt/derper-data /usr/local/cert
    sudo chmod 700 /opt/derper-data /usr/local/cert
    sudo find /usr/local/cert -type f -exec chmod 600 {} \;

日常排查优先看两处:

  • docker compose logs
  • 客户端的 tailscale debug derp <RegionID>tailscale netcheck

扩容更多 Region 时,按同样的模板复制 compose、替换域名、匹配当地 tailscaled revision,再在 derpMap 里加 Region。日常重启由 systemd 自动处理,版本升级则必须逐台、成对完成。

实现细节以 Tailscale 官方的 cmd/derper README 为准;其中明确写了同 revision、端口和 derpprobe 监控要求。客户端自动更新开关和手动更新方式见 Update Tailscale

评论