把 Homelab 安全带出门:Headscale 自建 Tailscale 控制平面实战

用 Headscale 自建 Tailscale 兼容控制平面,覆盖 Docker Compose 部署、Caddy 反代、客户端注册、Subnet Router、ACL、MagicDNS、备份迁移与常见排障。

前言:为什么有了 WireGuard 还要折腾 Headscale?

Homelab 远程访问最常见的方案是 WireGuard:性能好、配置简单、稳定。但随着设备变多,单纯的点对点 VPN 会遇到几个现实问题:手机、笔记本、云服务器、家里的 PVE 节点、NAS、Docker 主机都要互通;有些设备在双 NAT 或校园网后面;还希望按用户、标签、网段做访问控制。这时候 Tailscale 这类基于 WireGuard 的 Mesh VPN 就很香。

Headscale 是一个开源的 Tailscale 控制平面实现。简单说:数据面仍然走 WireGuard,控制面由你自己托管。客户端继续使用官方 Tailscale 客户端,只是登录服务器改成自己的 Headscale。对于 Homelab 来说,它很适合做一条「只暴露一个 HTTPS 入口,却能访问内网所有服务」的安全通道。

本文搭一套可直接落地的 Headscale:

  • 使用 Docker Compose 部署 Headscale;
  • 通过 Caddy 提供 HTTPS 反向代理;
  • 注册 Linux、Windows、手机等客户端;
  • 配置一台 Subnet Router,把 192.168.10.0/24 内网带出门;
  • 用 ACL 限制不同设备能访问的网段和端口;
  • 给出备份、迁移、升级和排障命令。

如果你只需要极简远程接入,可以先看站内的 WireGuard VPN 部署;如果你已经有多台设备、多用户、多网段,Headscale 的集中管控会更省心。


1. 架构设计:控制面、数据面和 DERP

Tailscale/Headscale 的关键点是分清两条路径:

组件作用是否经过 Headscale
Headscale 控制面设备注册、密钥分发、节点列表、ACL、MagicDNS✅ 是
WireGuard 数据面设备之间真实业务流量❌ 正常不经过
DERP 中继NAT 穿透失败时的中继通道❌ 默认用 Tailscale 公共 DERP
Subnet Router把某个内网网段宣告进 Tailnet❌ 只转发数据流量

很多人误以为 Headscale 会成为所有流量的代理瓶颈。实际上只要两台设备能 UDP 打洞成功,业务流量就是点对点直连;只有打洞失败时才会走 DERP 中继。Headscale 本身主要承载控制面,请求量不大,一台 1C/512MB 的 VPS 都能跑。

推荐部署位置:

部署位置优点缺点适合场景
公网 VPS客户端随时可注册,证书好签发多一台云主机🏆 最推荐
家中公网 IP低成本,完全自托管需要端口转发,家宽变更风险有公网 IPv4/IPv6
Cloudflare Tunnel 后面不开端口gRPC/长连接兼容性要仔细测临时实验

本文假设 Headscale 跑在一台公网 VPS 或有公网入口的 Docker 主机上,域名为:

1
hs.example.com

请替换成你自己的域名。


2. 准备目录和 Docker Compose

先创建目录:

1
2
sudo mkdir -p /opt/headscale/{config,data}
cd /opt/headscale

写入 compose.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
services:
  headscale:
    image: headscale/headscale:0.26.0
    container_name: headscale
    restart: unless-stopped
    command: serve
    volumes:
      - ./config:/etc/headscale
      - ./data:/var/lib/headscale
    ports:
      # 只监听本机,交给 Caddy 反代到公网
      - "127.0.0.1:8080:8080"
    environment:
      - TZ=Asia/Shanghai
    healthcheck:
      test: ["CMD", "headscale", "version"]
      interval: 30s
      timeout: 5s
      retries: 3

这里没有把 8080 直接暴露到公网,而是只绑定 127.0.0.1。这样即使云防火墙误放行 8080,外部也无法绕过反代直接访问 Headscale。

启动前先准备配置文件。


3. Headscale 配置文件

创建 /opt/headscale/config/config.yaml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
server_url: https://hs.example.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090

# Headscale 的持久化数据库。小规模 Homelab 用 SQLite 足够,备份也简单。
database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite

# 私有密钥路径,首次启动自动生成。这个文件和数据库都必须备份。
private_key_path: /var/lib/headscale/private.key
noise:
  private_key_path: /var/lib/headscale/noise_private.key

# IP 池:给 Tailnet 设备分配的虚拟地址,避免和家庭内网冲突。
prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
  allocation: sequential

# MagicDNS,方便用主机名访问设备。
dns:
  magic_dns: true
  base_domain: tailnet.example.com
  nameservers:
    global:
      - 223.5.5.5
      - 119.29.29.29
  search_domains: []
  extra_records: []

# ACL 策略文件。生产环境建议显式限制,不要长期全放通。
policy:
  mode: file
  path: /etc/headscale/acl.hujson

# 默认使用 Tailscale 公共 DERP,国内网络可后续自建 DERP 或优选区域。
derp:
  server:
    enabled: false
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  auto_update_enabled: true
  update_frequency: 24h

log:
  level: info

几个容易踩坑的点:

  1. server_url 必须是客户端能访问的最终 HTTPS 地址,不要写容器内地址;
  2. prefixes.v4 推荐保留 100.64.0.0/10,不要和家里 LAN、Docker 网段、K8s Pod CIDR 冲突;
  3. private.keynoise_private.keydb.sqlite 是核心资产,迁移时必须一起带走;
  4. policy.path 开启后,ACL 写错会影响客户端访问,改完要看日志确认加载成功。

4. Caddy 反向代理与 HTTPS

如果你已经有 Caddy 作为 Homelab 入口,可以加一段:

1
2
3
4
5
hs.example.com {
    encode zstd gzip

    reverse_proxy 127.0.0.1:8080
}

然后重载:

1
2
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
sudo systemctl reload caddy

如果 Caddy 也用 Docker,可以放到同一台主机上,核心原则仍然是:公网只开放 80/443,Headscale 的 8080 只允许本机或 Docker 内网访问。

用 curl 验证:

1
curl -I https://hs.example.com/health

正常应返回 200 OK。如果返回 502,先看 Caddy 是否能连到 127.0.0.1:8080;如果证书错误,检查域名解析和 80/443 端口是否放行。


5. 初始化与创建用户

启动 Headscale:

1
2
3
cd /opt/headscale
docker compose up -d
docker logs -f headscale

查看版本:

1
docker exec headscale headscale version

创建一个用户:

1
docker exec headscale headscale users create homelab

生成一次性预授权 Key,给 Linux 服务器批量接入很方便:

1
2
3
4
docker exec headscale headscale preauthkeys create \
  --user homelab \
  --expiration 24h \
  --reusable=false

会输出类似:

1
4c3f0d8a0bxxxxxxxxxxxxxxxxxxxxxxxx

这个 key 只用于注册设备,不要写进公开仓库。


6. 客户端接入

6.1 Linux 节点

在 PVE、NAS、Docker 主机或普通 Linux 服务器上安装 Tailscale 客户端:

1
curl -fsSL https://tailscale.com/install.sh | sh

使用 Headscale 登录:

1
2
3
4
sudo tailscale up \
  --login-server=https://hs.example.com \
  --authkey=你的预授权KEY \
  --hostname=pve1

查看状态:

1
2
tailscale status
tailscale ip -4

回到 Headscale 服务端查看节点:

1
docker exec headscale headscale nodes list

6.2 Windows/macOS/手机

桌面和移动端仍然安装官方 Tailscale 客户端,但登录方式略有不同:

1
tailscale up --login-server=https://hs.example.com

客户端会打开浏览器,提示你到 Headscale 注册。服务端日志或命令行会看到注册命令,执行后即可加入。例如:

1
2
3
docker exec headscale headscale nodes register \
  --user homelab \
  --key nodekey:xxxxxxxxxxxxxxxx

如果移动端 App 不方便指定 login server,可以先用桌面端验证链路,再考虑使用 Headscale 文档中对应平台的配置方式。生产环境建议优先把长期在线的服务器通过 authkey 接入,个人终端再手动注册。


7. 配置 Subnet Router:带出整个内网

假设家里主 LAN 是 192.168.10.0/24,PVE 节点 pve1 同时接入了 Headscale 和家庭内网。我们让 pve1 做 Subnet Router:

1
2
3
4
5
sudo tailscale up \
  --login-server=https://hs.example.com \
  --advertise-routes=192.168.10.0/24 \
  --accept-dns=false \
  --hostname=pve1-router

打开 Linux 转发:

1
2
3
4
5
cat <<'EOF' | sudo tee /etc/sysctl.d/99-tailscale-router.conf
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sudo sysctl --system

在 Headscale 服务端查看待批准路由:

1
2
docker exec headscale headscale nodes list
docker exec headscale headscale routes list

启用对应路由。不同版本命令输出略有差异,核心是找到 route ID 后 enable:

1
docker exec headscale headscale routes enable -r <ROUTE_ID>

其他客户端需要接受路由:

1
sudo tailscale set --accept-routes=true

验证:

1
2
3
# 在外部笔记本上测试访问内网 PVE 或 NAS
ping 192.168.10.2
curl -k https://192.168.10.2:8006

如果 ping 不通,按这个顺序查:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 1. 客户端是否拿到了路由
tailscale status
tailscale netcheck
ip route | grep 192.168.10

# 2. Subnet Router 是否开启转发
sysctl net.ipv4.ip_forward

# 3. Headscale 是否启用了路由
docker exec headscale headscale routes list

# 4. 内网目标机器的默认网关/防火墙是否允许来自 100.64.0.0/10 的访问
sudo iptables -S
sudo nft list ruleset

实战经验:很多「Subnet Router 不通」不是 Headscale 的锅,而是内网目标机器的防火墙拒绝了 100.64.0.0/10,或者路由器不知道如何回包。Tailscale 默认会在 Subnet Router 上做 SNAT,通常能避免回程路由问题;如果你禁用了 SNAT,就必须在家庭网关上加静态路由。


8. ACL:不要让所有设备访问所有端口

默认全放通很方便,但不适合长期使用。下面是一份适合 Homelab 的 acl.hujson 示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
{
  "groups": {
    "group:admins": ["homelab"]
  },

  "tagOwners": {
    "tag:server": ["group:admins"],
    "tag:router": ["group:admins"],
    "tag:mobile": ["group:admins"]
  },

  "acls": [
    // 管理员设备可以访问所有 Tailnet 节点
    {
      "action": "accept",
      "src": ["group:admins"],
      "dst": ["*:*"]
    },

    // 普通移动设备只允许访问内网 Web 服务和 SSH 跳板
    {
      "action": "accept",
      "src": ["tag:mobile"],
      "dst": [
        "192.168.10.0/24:80,443,8006,8123,9000",
        "tag:server:22"
      ]
    }
  ],

  "ssh": [
    {
      "action": "accept",
      "src": ["group:admins"],
      "dst": ["tag:server"],
      "users": ["root", "ubuntu"]
    }
  ],

  "autoApprovers": {
    "routes": {
      "192.168.10.0/24": ["tag:router"]
    }
  }
}

保存到 /opt/headscale/config/acl.hujson 后重启:

1
2
docker compose restart headscale
docker logs --tail=100 headscale

给节点打标签通常在注册或重新登录时指定,例如:

1
2
3
4
sudo tailscale up \
  --login-server=https://hs.example.com \
  --advertise-tags=tag:server \
  --hostname=docker01

ACL 的维护建议:

策略建议
管理员设备可以全访问,但设备数量越少越好
手机/平板只放行 Web 服务端口,不给 SSH
Subnet Router单独打 tag:router,配合 autoApprovers
临时设备用短过期 preauthkey,不用后删除节点

9. MagicDNS:用名字访问 Homelab 服务

开启 magic_dns 后,Tailnet 内可以通过节点名访问设备,例如:

1
2
ssh root@pve1
curl http://docker01:9000

如果想给某些内网服务配置固定记录,可以使用 extra_records

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
dns:
  magic_dns: true
  base_domain: tailnet.example.com
  nameservers:
    global:
      - 223.5.5.5
  extra_records:
    - name: pve.tailnet.example.com
      type: A
      value: 192.168.10.2
    - name: nas.tailnet.example.com
      type: A
      value: 192.168.10.10

但我更推荐两种方式:

  1. 节点用 MagicDNS 名称,比如 pve1docker01
  2. 业务服务继续走内网反代域名,比如 https://grafana.home.example.com,DNS 指向内网反代 IP,再由 Subnet Router 打通访问。

这样不会把 Headscale 的 DNS 配置写得过重,后续迁移也简单。


10. 备份、升级与迁移

Headscale 的关键数据都在 /opt/headscale/data/opt/headscale/config。最小备份脚本如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR="/backup/headscale"
TS="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"

cd /opt
tar -czf "$BACKUP_DIR/headscale-$TS.tar.gz" headscale/config headscale/data

# 保留最近 14 份
ls -1t "$BACKUP_DIR"/headscale-*.tar.gz | tail -n +15 | xargs -r rm -f

配合 crontab:

1
sudo crontab -e

写入:

1
15 3 * * * /usr/local/sbin/backup-headscale.sh >/var/log/backup-headscale.log 2>&1

升级前先备份,再拉镜像:

1
2
3
4
5
6
cd /opt/headscale
./backup-headscale.sh

docker compose pull
docker compose up -d
docker logs --tail=100 headscale

迁移到新机器时:

1
2
3
4
5
# 新机器
sudo mkdir -p /opt/headscale
sudo tar -xzf headscale-20260601-030000.tar.gz -C /opt
cd /opt/headscale
docker compose up -d

迁移后要确认:

1
2
3
docker exec headscale headscale users list
docker exec headscale headscale nodes list
curl -I https://hs.example.com/health

只要域名、数据库和私钥没变,客户端通常不需要重新注册。


11. 常见踩坑记录

❌ 1. server_url 写成了 http://127.0.0.1:8080

客户端注册时会拿这个地址作为控制面入口。如果写成本机地址,只有容器自己能访问,外部客户端全部失败。必须写公网可访问的 HTTPS 地址:

1
server_url: https://hs.example.com

❌ 2. 反代正常,但客户端一直连不上

先看基础健康检查:

1
2
curl -v https://hs.example.com/health
docker logs --tail=200 headscale

再检查 Caddy 是否把请求转发到 Headscale:

1
2
sudo journalctl -u caddy -n 100 --no-pager
ss -lntp | grep 8080

如果 8080 没监听,多半是容器没起来或配置文件解析失败。

❌ 3. Subnet Router 已批准,但访问内网服务超时

重点查三件事:

1
2
3
4
5
6
7
8
9
# 客户端是否接受路由
sudo tailscale set --accept-routes=true
ip route | grep 192.168.10

# Router 是否开启转发
sysctl net.ipv4.ip_forward

# 目标服务是否只监听 localhost
ss -lntp | grep ':8006\|:8123\|:443'

有些服务只绑定 127.0.0.1,你从 VPN 当然访问不到。需要改成监听 0.0.0.0 或内网 IP。

❌ 4. Docker 网段和家里 LAN 冲突

如果 Docker 默认 172.17.0.0/16、K8s Pod CIDR、家里路由器网段、Tailnet 路由互相重叠,排障会非常痛苦。建议统一规划:

1
2
3
4
5
6
家庭 LAN:        192.168.10.0/24
服务器 VLAN:     192.168.20.0/24
Docker Compose:  172.20.0.0/16
K8s Pod CIDR:    10.42.0.0/16
K8s SVC CIDR:    10.43.0.0/16
Tailnet:         100.64.0.0/10

Docker 默认地址池可以在 /etc/docker/daemon.json 中调整:

1
2
3
4
5
6
7
8
{
  "default-address-pools": [
    {
      "base": "172.20.0.0/14",
      "size": 24
    }
  ]
}

重启 Docker:

1
sudo systemctl restart docker

12. 安全加固清单

项目命令/配置建议
公网端口ss -lntp、云防火墙只开放 80/443/SSH
Headscale 端口Compose 绑定 127.0.0.1:8080不直接暴露公网
预授权 Key--expiration 24h --reusable=false临时使用,过期失效
ACLpolicy.path默认拒绝,按需放行
备份tar config + data至少每日一次
日志docker logs headscale注册失败先看日志
升级固定镜像 tag不建议长期用 latest

如果你还把 Headscale 暴露在公网 VPS 上,SSH 也要做基础加固:禁用密码登录、改用密钥、限制 sudo 用户,并配合防火墙只放行必要端口。


总结:Headscale 适合谁?

Headscale 不是 WireGuard 的替代品,而是把 WireGuard 的设备管理、NAT 穿透、ACL 和 DNS 做成了一个自托管控制面。对于 Homelab,它最有价值的地方是:不用把 PVE、NAS、Home Assistant、Grafana 全部暴露到公网,只需要维护一个 HTTPS 控制面,就能在外面安全访问内网资源

我的建议:

场景推荐方案
只有 1-2 台设备远程访问WireGuard 更简单
多设备、多系统、经常换网络Headscale 更省心 🏆
需要访问整个家庭网段Headscale + Subnet Router
多用户权限隔离Headscale + ACL
极致可控和审计自建 Headscale + 自建 DERP

最后一句话:先把 Headscale 控制面跑稳,再逐步加 Subnet Router、ACL 和 DNS。不要一上来就把所有内网网段全放通,否则你只是把「内网裸奔」换成了「VPN 裸奔」。

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计