用 Docker 部署 sing-box 服务端:与 Caddy 并存且共用 443
之前想把这套方案写成一篇从买服务器、买域名、搭建服务端到配置客户端的完整教程,后来发现这类文章很容易变成配置项说明书。这里就换一种写法:只记录这套部署方案的特点、组件之间怎么配合,以及我认为最值得留意的几个坑。
本文的服务端使用 Docker 部署 sing-box,同时提供 Hysteria2 和 VLESS XTLS-Reality 两个节点。Caddy 负责管理站点,并通过 L4 插件与 sing-box 共用宿主机的 443 端口。
本文不是面向新手的逐项复制教程。文末配置已经脱敏,域名、UUID、密码、Reality 私钥和短 ID 都必须替换成自己的值。
目录结构(点击展开)
Proxy/
├── .env # 域名、密钥和密码(不要提交到 Git)
├── compose.yml
├── caddy/
│ ├── Dockerfile # 构建带 caddy-l4 的 Caddy
│ ├── conf/Caddyfile
│ ├── data/ # Caddy/证书数据
│ ├── config/
│ └── site/ # 博客静态文件
└── sing-box/
├── config.json.template # envsubst 输入
├── config.json # 渲染后的配置,不要提交
└── certmagic/ # sing-box ACME 数据这套方案解决了什么问题
如果只运行 sing-box,Hy2 和 VLESS 都可以直接监听服务器的 443;但同一台机器上的 Caddy 也想提供 HTTPS 站点时,两个进程不能同时绑定同一个宿主端口。这里采用“Caddy 持有宿主机端口,sing-box 使用容器内部端口”的方式:
客户端
│ TCP/443 或 UDP/443
▼
Caddy(宿主 80/443)
├─ TLS/QUIC SNI 是 HY2_DOMAIN 或 REALITY_DOMAIN
│ └─ sing-box:443
└─ 其他 TCP TLS 流量
└─ Caddy 自己的 HTTPS :8443
HTTP :80
├─ sing-box ACME challenge ── sing-box:80
└─ 其他站点
Caddy 使用 caddy-l4 在连接还没有进入 HTTP/HTTPS 处理阶段时读取 TLS SNI,因而可以把指定域名的 TCP 连接转发给 sing-box。UDP 则通过 QUIC 的 SNI 做同样的判断,Hy2 的 UDP 流量因此也能走到 sing-box。
这里的 https_port 8443 不是让外部用户访问 8443,而是让 Caddy 把自己的 HTTPS 服务放到容器内的 8443。Caddy L4 收到宿主机 443 的其他 TCP 流量后,再转发到 127.0.0.1:8443。外部仍然只需要开放 80 和 443。
Hy2 和 VLESS Reality
Hysteria2
Hy2 基于 QUIC/UDP,特点是对高延迟、丢包或带宽变化的网络比较友好。
服务端配置里,Hy2 由 sing-box 的 ACME certificate provider 申请证书。
VLESS XTLS-Reality
VLESS Reality 走 TCP,并使用 xtls-rprx-vision flow。它不需要为 VLESS 域名单独申请一张真实证书, 但同样要求 sing-box 持有 tls。
Caddy 与 sing-box 共用 443 的关键点
这部分是整套部署最容易出错的地方。
1. 只有 Caddy 映射宿主端口
compose.yml 中只有 Caddy 声明:
ports:
- "80:80/tcp"
- "443:443/tcp"
- "443:443/udp"
sing-box 不使用 network_mode: host,也不再把 443 映射到宿主机。它在 Compose 网络中监听 0.0.0.0:443,Caddy 通过服务名 sing-box:443 访问它。
2. Caddy 必须带 L4 插件
标准 Caddy 主要处理 HTTP,不能直接按 TLS SNI 和 QUIC SNI 转发原始 TCP/UDP。caddy/Dockerfile 使用 xcaddy 构建,并加入 github.com/mholt/caddy-l4。升级 Caddy 时也要重新构建这个镜像。
3. 443 的 TCP 和 UDP 都要放行
TCP 443 负责 VLESS Reality,UDP 443 负责 Hy2。只写一条 443:443/tcp 映射时,VLESS 可能正常而 Hy2 失败;反过来也一样。服务器安全组和系统防火墙需要同步放行 TCP/UDP 80、443。
4. HTTP-01 challenge 仍然走 80
sing-box 的 certificate_providers 使用 Let's Encrypt 的 HTTP-01 challenge 时,Caddy 不能把所有 80 端口请求都截走。Caddyfile 将 /.well-known/acme-challenge/* 且主机名匹配 Hy2/Reality 域名的请求反向代理到 sing-box:80,让 sing-box 完成验证。
这样 Caddy 仍然可以管理自己的 HTTPS 站点和 tls 证书,同时不干扰 sing-box 的证书申请。
当然,sing-box 中证书提供者质询方式改为 DNS-01 质询会更加方便,DNS-01 不要求开放的 80 443 端口
配置变量和客户端参数
建议把敏感值集中在 .env,让 config-render 容器用 envsubst 渲染 config.json.template。渲染后的 sing-box/config.json 不应提交到 Git。
至少需要准备这些值:
| 变量 | 用途 |
|---|---|
HY2_DOMAIN | Hy2 域名,解析到服务器,并用于 ACME |
REALITY_DOMAIN | Reality 的 SNI/握手域名 |
MAIN_DOMAIN | Caddy HTTP 响应使用的主域名 |
BLOG_DOMAIN | Caddy 静态博客域名 |
ACME_EMAIL | Let's Encrypt 联系邮箱 |
VLESS_UUID | VLESS 用户 UUID |
REALITY_PRIVATE_KEY | Reality 服务端私钥 |
REALITY_SHORT_ID | Reality short ID |
HY2_PASSWORD | Hy2 用户密码 |
其实我用 .env 只是因为懒,避免换个域名就要在 sing-box 配置和 caddy 配置里到处改,没见得安全多少(
客户端不需要理解 Caddy 的存在,只需要填写最终的协议参数:
- Hy2:服务器地址、端口
443、密码、SNI 为HY2_DOMAIN,并启用 UDP/QUIC; - VLESS:服务器地址、端口
443、UUID、flowxtls-rprx-vision,tls 启用 Reality,SNI/服务器名为REALITY_DOMAIN,公钥和 short ID 使用服务端生成的对应值。
脱敏后的配置
仅供参考
`.env`(点击展开)
HY2_DOMAIN=hy2.example.com
REALITY_DOMAIN=www.example.net
MAIN_DOMAIN=example.com
BLOG_DOMAIN=blog.example.com
ACME_EMAIL=admin@example.com
VLESS_UUID=00000000-0000-0000-0000-000000000000
REALITY_PRIVATE_KEY=replace-with-reality-private-key
REALITY_SHORT_ID=0123456789abcdef
HY2_PASSWORD=replace-with-a-long-random-password`compose.yml`(点击展开)
services:
config-render:
image: dibi/envsubst:latest
container_name: config-render
restart: "no"
env_file:
- ./.env
volumes:
- ./sing-box/config.json.template:/workdir/config.json:ro
- ./sing-box:/processed
caddy:
build:
context: ./caddy
dockerfile: Dockerfile
container_name: caddy
restart: unless-stopped
cap_add:
- NET_ADMIN
ports:
- "80:80/tcp"
- "443:443/tcp"
- "443:443/udp"
env_file:
- ./.env
volumes:
- ./caddy/conf:/etc/caddy
- ./caddy/data:/data
- ./caddy/config:/config
- ./caddy/site:/srv
sing-box:
image: ghcr.io/sagernet/sing-box:latest-testing
container_name: sing-box
restart: always
depends_on:
config-render:
condition: service_completed_successfully
caddy:
condition: service_started
volumes:
- ./sing-box:/workdir
command: -D /workdir run`caddy/Dockerfile` 和 `Caddyfile`(点击展开)
FROM caddy:2-builder AS builder
RUN xcaddy build \
--with github.com/mholt/caddy-l4@v0.1.1
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy{
http_port 80
https_port 8443
layer4 {
tcp/:443 {
@singbox tls sni {$HY2_DOMAIN} {$REALITY_DOMAIN}
route @singbox { proxy tcp/sing-box:443 }
route { proxy tcp/127.0.0.1:8443 }
}
udp/:443 {
@singbox quic sni {$HY2_DOMAIN} {$REALITY_DOMAIN}
route @singbox { proxy udp/sing-box:443 }
route { proxy udp/127.0.0.1:8443 }
}
}
}
http://:80 {
@singbox_acme {
host {$HY2_DOMAIN} {$REALITY_DOMAIN}
path /.well-known/acme-challenge/*
}
@main { host {$MAIN_DOMAIN} }
route {
reverse_proxy @singbox_acme sing-box:80
respond @main "OK" 200
respond 404
}
}
{$MAIN_DOMAIN} { respond "OK" 200 }
{$BLOG_DOMAIN} {
root * /srv
file_server
}`sing-box/config.json.template`(点击展开)
{
"log": { "level": "error" },
"http_clients": [
{ "tag": "direct", "engine": "go", "version": 2 }
],
"certificate_providers": [
{
"type": "acme",
"tag": "MyCert",
"domain": ["${HY2_DOMAIN}"],
"data_directory": "/workdir/certmagic",
"email": "${ACME_EMAIL}",
"provider": "letsencrypt",
"http_client": "direct"
}
],
"inbounds": [
{
"type": "vless",
"tag": "vless-in",
"listen": "0.0.0.0",
"listen_port": 443,
"users": [{
"name": "user",
"uuid": "${VLESS_UUID}",
"flow": "xtls-rprx-vision"
}],
"tls": {
"enabled": true,
"server_name": "${REALITY_DOMAIN}",
"reality": {
"enabled": true,
"handshake": {
"server": "${REALITY_DOMAIN}",
"server_port": 443
},
"private_key": "${REALITY_PRIVATE_KEY}",
"short_id": ["${REALITY_SHORT_ID}"],
"max_time_difference": "1m"
}
}
},
{
"type": "hysteria2",
"tag": "hy2-in",
"listen": "0.0.0.0",
"listen_port": 443,
"users": [{
"name": "user",
"password": "${HY2_PASSWORD}"
}],
"tls": {
"enabled": true,
"server_name": "${HY2_DOMAIN}",
"certificate_provider": "MyCert"
}
}
],
"outbounds": [{ "type": "direct", "tag": "direct" }],
"route": {
"rules": [{ "ip_is_private": true, "action": "reject" }],
"final": "direct",
"auto_detect_interface": true
}
}