用 Docker 部署 sing-box 服务端:与 Caddy 并存且共用 443

· 6min · sorubedo

之前想把这套方案写成一篇从买服务器、买域名、搭建服务端到配置客户端的完整教程,后来发现这类文章很容易变成配置项说明书。这里就换一种写法:只记录这套部署方案的特点、组件之间怎么配合,以及我认为最值得留意的几个坑。

本文的服务端使用 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_DOMAINHy2 域名,解析到服务器,并用于 ACME
REALITY_DOMAINReality 的 SNI/握手域名
MAIN_DOMAINCaddy HTTP 响应使用的主域名
BLOG_DOMAINCaddy 静态博客域名
ACME_EMAILLet's Encrypt 联系邮箱
VLESS_UUIDVLESS 用户 UUID
REALITY_PRIVATE_KEYReality 服务端私钥
REALITY_SHORT_IDReality short ID
HY2_PASSWORDHy2 用户密码

其实我用 .env 只是因为懒,避免换个域名就要在 sing-box 配置和 caddy 配置里到处改,没见得安全多少(

客户端不需要理解 Caddy 的存在,只需要填写最终的协议参数:

  • Hy2:服务器地址、端口 443、密码、SNI 为 HY2_DOMAIN,并启用 UDP/QUIC;
  • VLESS:服务器地址、端口 443、UUID、flow xtls-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
  }
}