从零部署 workbuddy2api 网关:Docker 编排、NPM 反代与 DeepSeek Harness 接入
独立预览版 · 正文内容与「粘贴版」完全一致 · 粘贴到 WordPress 时请只复制下方 <div class=”doc”> 整块
本文记录一套完整的自建 AI 网关部署方案:用 Docker 部署 workbuddy2api(网关 + Web 管理台两个容器),把它接进既有的 Nginx Proxy Manager 反向代理,签发 Let’s Encrypt 证书并开启 HTTPS,最后把网关接入 DeepSeek Harness(dsh)。
反代与证书部分全部在 NPM 面板里点几下完成,不需要敲命令、不需要调 API。全程不新起宿主机 Nginx、不改动既有站点,新增服务只挂在已有的 Docker 网络里,对线上环境接近零侵入。
已有一台跑着 Nginx Proxy Manager 的 VPS,希望在同一个反代下再挂一个 OpenAI 兼容的模型网关,并把统一出口提供给 dsh、Cursor、Cline、Continue、Codex CLI 等客户端。
本文不包含任何具体服务器的公网 IP、SSH 端口、真实域名、API Key、后台地址或其他可识别服务器的信息。
文中
api.example.com、203.0.113.10 均为占位符,发布与使用时请替换为自己的实际参数。
前置步骤:环境与规划
这套方案的思路是复用既有反代,而不是另起一套。因此前置条件里最重要的不是装软件,而是先确认「80 / 443 归谁管」。
0.1 环境要求
| 项目 | 要求 |
|---|---|
| 操作系统 | Debian 12 / 13 或 Ubuntu 22.04+ |
| Docker | 24 以上,含 docker compose 插件 |
| Python 3 | 用于生成网关配置文件 |
| jq | 查看网关接口返回(可选) |
| Nginx Proxy Manager | 已在 Docker 中运行,且能登录管理后台 |
| DNS | 一个已解析到本机的(子)域名 |
安装基础工具:
apt update
apt install -y ca-certificates curl git python3 jq
0.2 地址与端口规划
本文统一把新服务挂进已有的 web 网络(172.20.0.0/16),并使用静态 IP。
| 组件 | 容器 IP | 端口 | 说明 |
|---|---|---|---|
npm |
172.20.0.20 |
80 / 443 / 81 | 既有反代 |
workbuddy2api |
172.20.0.60 |
7863 | 网关 |
wb2api-admin |
172.20.0.61 |
7864 | Web 管理台 |
如果还没有这个网络,先创建:
docker network create \
--driver bridge \
--subnet 172.20.0.0/16 \
--gateway 172.20.0.1 \
web
第 3 步要在 NPM 面板里手工填转发目标 IP。若让 Docker 动态分配,容器重建后 IP 可能变化,
反代会静默指向错误地址(表现为 502)。固定 IP 后,容器重建、镜像升级都不需要改反代配置。
0.3 关键前提:443 已经被占用了怎么办
很多 VPS 上 443 端口已经被别的 TLS 服务占用(例如 Reality 入站)。这种情况下有两条路:
- 443 空闲:直接把 443 交给 NPM,正常使用即可。
- 443 已被占用:NPM 只发布 80 端口,由占用 443 的服务把不匹配的流量 fallback 到 NPM 容器的 443。此时 NPM 容器内的 443 仍然正常工作,只是入口不在宿主机上。
不要为了这个网关去宿主机上另起一个 Nginx 抢 80 / 443。宿主机端口冲突会让既有站点直接下线,
而且证书续期、日志、配置全都变成两套。正确做法是接进既有 NPM。
第 1 步:部署网关与管理台
项目目录统一放在 /opt/workbuddy2api。
1.1 拉取仓库
git clone --depth 1 https://github.com/dddmiku/workbuddy2api.git /opt/workbuddy2api
cd /opt/workbuddy2api
--depth 1 只拉最新一次提交,部署用足够了。仓库基于 MIT 协议的Sliverkiss/workbuddy2api 二次开发,镜像是ghcr.io/dddmiku/workbuddy2api:latest 与ghcr.io/dddmiku/workbuddy2api-panel:latest。
1.2 生成 API Key 并写出 config.json
网关的对外密钥在这里一次性生成,后面所有客户端都用它。不要手写一个弱口令。
API_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
export API_KEY
python3 - <<'PY'
import json, os
cfg = json.load(open("config.example.json", encoding="utf-8"))
cfg["api_key"] = os.environ["API_KEY"]
cfg["api_keys_file"] = "./data/api_keys.json"
cfg["api_keys_socket"] = "./data/api_keys.sock"
cfg["usage_file"] = "./data/usage.json"
json.dump(cfg, open("config.json", "w", encoding="utf-8"), indent=2, ensure_ascii=False)
print("config.json 已生成")
PY
三个路径字段分别负责:多密钥文件、管理台与网关通信的 Unix socket、用量统计落盘。管理台要靠它们才能读到实时状态。
1.3 目录与属主
网关容器以固定非 root 身份
10001:10001 运行。宿主机上挂进去的目录如果属主不对,容器虽然能起来,但授权文件写不进去,表现为「登录成功了但账号列表一直是空的」。
install -d -o 10001 -g 10001 -m 700 auths data
mkdir -p panel-data
chown 10001:10001 config.json
chmod 600 config.json
ls -ld auths data panel-data
确认 auths 与 data 的属主是 10001 10001。
1.4 编写编排文件
创建 docker-compose.yml:
cd /opt/workbuddy2api
nano docker-compose.yml
services:
wb2api:
image: ghcr.io/dddmiku/workbuddy2api:latest
container_name: workbuddy2api
restart: unless-stopped
stop_grace_period: 3m
environment:
- TZ=Asia/Shanghai
ports:
- "127.0.0.1:7863:7863"
volumes:
- ./auths:/app/auths
- ./data:/app/data
- ./config.json:/app/config.json:ro
networks:
web:
ipv4_address: 172.20.0.60
wb2api-admin:
image: ghcr.io/dddmiku/workbuddy2api-panel:latest
container_name: wb2api-admin
restart: unless-stopped
environment:
- TZ=Asia/Shanghai
- WB2API_GATEWAY_DIR=/gateway
- WB2API_ADMIN_DIR=/data
- WB2API_ADMIN_HOST=0.0.0.0
- WB2API_GATEWAY_URL=http://wb2api:7863
- WB2API_CONTAINER=workbuddy2api
ports:
- "127.0.0.1:7864:7864"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./:/gateway
- ./panel-data:/data
depends_on:
- wb2api
networks:
web:
ipv4_address: 172.20.0.61
networks:
web:
external: true
三个设计点值得单独说明:
| 配置 | 作用 |
|---|---|
stop_grace_period: 3m |
升级容器时给正在进行的流式请求留足时间,避免长回答被硬中断 |
127.0.0.1:7863:7863 |
只绑回环地址。写成 7863:7863 会直接暴露到公网,绕过反代和 HTTPS |
wb2api-admin 挂 docker.sock |
管理台需要重启网关容器来加载新授权,这是它唯一的特权来源 |
./:/gateway:管理台需要直接读写项目目录下的
auths/,而它自己是个独立容器。挂载整个项目目录后,两个容器看到的是同一份授权数据。
1.5 启动
docker compose pull
docker compose up -d
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
确认两个容器都是 Up。然后本机自测:
curl -s http://127.0.0.1:7863/healthz
echo
curl -s -o /dev/null -w 'admin login -> %{http_code}\n' http://127.0.0.1:7864/login
healthy:0 是正常的:此时还没有授权任何上游账号,网关自报
healthy:0 属于预期状态,不是部署失败。等第 4 步授权完成后,这个数字会变成实际可用账号数。
第 2 步:接入既有 Docker 网络
上一步的编排里已经写死了 web 网络和静态 IP。这一步只做确认。
2.1 确认网络存在
docker network inspect web --format '{{.Name}} {{.IPAM.Config}}'
应输出 web [{172.20.0.0/16 172.20.0.1}] 之类的信息。
2.2 确认容器真的拿到了预期 IP
docker inspect -f '{{.Name}} -> {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
workbuddy2api wb2api-admin
期望看到:
/workbuddy2api -> 172.20.0.60
/wb2api-admin -> 172.20.0.61
2.3 验证 NPM 能连通网关
不要用宿主机 curl,要从 NPM 容器内部测,才能验证容器间网络:
docker exec npm sh -c \
'wget -qO- --timeout=10 http://172.20.0.60:7863/healthz'
能返回 JSON 就说明反代链路的第一段已经通了。
第 3 步:在 NPM 面板里配置反代与 HTTPS
这一步全程在浏览器里点,不碰命令行。要做的就是一件事:新建一个 Proxy Host,把域名指向网关,顺手申请证书。
3.1 新建 Proxy Host
登录 NPM 后台,进入 Hosts → Proxy Hosts → Add Proxy Host,在 Details 标签页填写:
| 字段 | 填什么 |
|---|---|
| Domain Names | api.example.com |
| Scheme | http |
| Forward Hostname / IP | 172.20.0.60 |
| Forward Port | 7863 |
| Cache Assets | 不勾 |
| Block Common Exploits | 勾上 |
| Websockets Support | 勾上 |
① Forward Hostname / IP 填容器静态 IP
172.20.0.60,不要填 localhost 或 127.0.0.1——NPM 自己也在容器里,回环地址指的是它自己。② 这里连的是网关容器,不是管理台。管理台走下面 3.2 的单独路径。
3.2 高级:把 /admin/ 单独转给管理台
切到 Advanced 标签页,在 Custom Nginx Configuration 文本框里粘贴:
location /admin/ {
proxy_pass http://172.20.0.61:7864/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
这样同一个域名下:/ 走网关 API,/admin/ 走管理台,两者互不干扰。
①
proxy_pass 末尾的斜杠不能省。少了它,/admin/login 会被原样转给管理台,而管理台的路径是
/login,结果就是 404。②
proxy_read_timeout 600s 要保留。管理台授权时要长时间轮询上游登录状态,默认 60 秒会被切断。
3.3 SSL:申请证书并开启 HTTPS
切到 SSL 标签页:
| 字段 | 填什么 |
|---|---|
| SSL Certificate | 下拉选 Request a new SSL Certificate |
| Force SSL | 勾上 |
| HTTP/2 Support | 勾上 |
| HSTS Enabled | 不勾 |
| HSTS Subdomains | 不勾 |
| Email Address | 你的邮箱(证书到期提醒) |
| I Agree to the Let’s Encrypt Terms of Service | 勾上 |
点 Save。剩下的 NPM 自己会做完:
↓
找出所有占用这个域名的 host
↓
临时禁用它们(腾出 80 端口)
↓
写一份只服务 ACME 校验的临时配置
↓
Let’s Encrypt 完成验证、签发证书
↓
删掉临时配置
↓
恢复被禁用的 host、挂上证书
该条目右侧会显示证书到期时间,访问
https:// 也是绿锁了。
网上(包括本博客的早期版本)有说法称「首次签发需要手动补
location ^~ /.well-known/acme-challenge/」,这是误判。NPM 自带上面那套引导流程,会自己解决「还没有证书时怎么完成校验」的问题,不需要你插手。
更麻烦的是:一旦启用 SSL,NPM 会自动往这个 vhost 里注入
include conf.d/include/letsencrypt-acme-challenge.conf,而那个文件里就含同名 location。你再手写一份,nginx 会报 duplicate location,而 NPM 的处理方式是静默丢弃整个生成的配置文件:
数据库已更新、面板不报错、手工跑
nginx -t 还是绿的(因为文件根本没写进去),但站点访问时报 TLS
unrecognized name。判断方法很简单,直接看文件在不在:
docker exec npm ls /data/nginx/proxy_host/
3.4 验证
域名解析生效后(或加 --resolve 从本机直连),逐项确认:
# 不带密钥:应为 401,说明鉴权在工作
curl -sS -o /dev/null -w '%{http_code}\n' \
https://api.example.com/v1/models
# 证书链校验:tls=0 才算真通过
curl -sS -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' \
https://api.example.com/v1/models
# 证书主体
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
# 管理台
curl -sS -o /dev/null -w '%{http_code}\n' \
https://api.example.com/admin/login
期望结果:
| 检查 | 期望 |
|---|---|
无密钥访问 /v1/models |
401 |
| 证书链校验 | tls=0 |
| 证书主体 | CN=api.example.com,签发者 Let’s Encrypt |
/admin/login |
200 |
在 curl 后面加
--resolve api.example.com:443:<服务器IP>,就能跳过 DNS 直接从本机验证反代和证书。注意证书校验要不带
-k,看到
tls=0 才说明证书链完整、域名匹配。
第 4 步:初始化管理台并授权账号
4.1 生成管理台初始密码
管理台首次启动不会自动建账号,需要手动触发一次凭据初始化:
cd /opt/workbuddy2api
docker compose exec wb2api-admin python3 -c \
'import sys; sys.path.insert(0, "/app"); import app; app.load_credentials()'
随后初始密码会写在 panel-data/initial-password.txt(权限 600),账号名是 wbadmin。
cat panel-data/initial-password.txt
ls -la panel-data/
拿到密码后立即修改,并确认该文件权限是
600。管理台挂着
docker.sock,等于持有宿主机 Docker 的完全控制权,不能弱口令。
4.2 添加上游账号
登录 https://api.example.com/admin/,点「添加账号」,流程是两步:
- 选择区域:国内版 CN 或 国际版 Global;
- 点「生成授权链接」,面板会显示二维码和一段明文 URL。
点弹窗里的「复制链接」,把 URL 贴到电脑浏览器打开即可完成授权,
不需要安装对应区域的手机 App。登录方式建议选「手机号」,纯网页 + 短信验证码,最稳。
授权成功后页面会提示「请返回 CLI 继续」,直接等面板轮询即可,不需要在页面上额外点「授权」。
wb2api-admin:登录 state 保存在面板进程的内存里。容器一重启,正在进行的授权流程就作废,只能重新生成链接。
习惯命令行的也可以用容器内的登录脚本,按区域分别执行:
docker compose run --rm --entrypoint /bin/bash wb2api \
/app/login.sh --realm=global
# 国内版用
# /app/login.sh --realm=cn
4.3 验证账号已生效
面板每几秒自动轮询一次,授权成功后会自动写进 auths/ 并重启网关。之后检查:
curl -s http://127.0.0.1:7863/healthz | jq .
docker compose logs --tail=20 wb2api
期望 healthy 变成实际账号数,并且两个区域都可服务。
curl -s -H "Authorization: Bearer $API_KEY" \
http://127.0.0.1:7863/v1/models | jq '.data | length'
能列出模型清单就说明网关真正可用了。
第 5 步:整体验收
按下面这张表逐项过一遍,全绿才算部署完成。
| 项目 | 验证方式 | 期望 |
|---|---|---|
| 网关存活 | curl -s http://127.0.0.1:7863/healthz |
JSON,healthy > 0 |
| 鉴权生效 | 无密钥请求 /v1/models |
401 |
| 模型清单 | 带密钥请求 /v1/models |
200 + 模型数组 |
| 公网 HTTPS | 直连 443 不带 -k |
401 tls=0 |
| 证书主体 | openssl s_client |
CN=api.example.com |
| 管理台 | /admin/login |
200 |
| 管理台接口隔离 | 未登录访问 /admin/api/* |
401(路径剥离正确) |
| 既有域名回归 | 逐个 curl 原站点 | 全 200 |
最后一项尤其重要——我们在 NPM 里新增了一个 vhost,必须确认老站点没被牵连:
for d in wp.example.com panel.example.com blog.example.com; do
code=$(curl -sS -o /dev/null -w '%{http_code}' \
--resolve "$d:443:127.0.0.1" "https://$d/" --max-time 15 2>/dev/null)
echo "$d -> $code"
done
200,说明新增的 vhost 没有影响任何原有服务。
第 6 步:接入 DeepSeek Harness
网关上线后,任何 OpenAI 兼容客户端都可以直接用它。这里以 DeepSeek Harness(dsh)为例。
6.1 启动 dsh
cd /你的项目绝对路径
npx @deepseek-ai/dsh web
默认打开 http://127.0.0.1:3080,端口被占用时加 --port 3081。
npx 就够了:dsh 还在 Developer Preview 阶段,版本更新很快,不必全局安装。
启动目录会作为默认工作区,别在用户主目录下启动。
6.2 添加自定义提供方
打开 设置 → 模型 → 添加自定义提供方,按下表填写:
| 字段 | 填什么 |
|---|---|
| Provider ID | wb2api |
| 显示名称 | WorkBuddy2API |
| API 地址 | https://api.example.com/v1 |
| API 协议 | openai-completions |
| API 密钥 | 第 1.2 步生成的 $API_KEY |
| 模型 | 见下 |
请求、会话、凭据引用都用它,想改名只能新建一个再删旧的。
显示名称、地址、协议、密钥、模型都还能改。
6.3 拉取模型清单
点「获取可用模型」,dsh 会请求 GET /v1/models。本网关支持这个端点,能直接列出全部模型,在搜索框里勾选即可。
global: 走国际版账号,cn: 走国内版账号,网关按前缀自动选号。模型总数是固定的,但两个前缀的拆分每次枚举会有小幅浮动,按 ID 找就行,别记数量。
6.4 可选:配置推理等级
dsh 的推理等级菜单只对声明了等级的模型出现,手动录入的模型默认没有。
需要在设置页顶部点「打开配置文件」(等价于编辑 ~/.dsh/settings.yaml)手动加上:
llm-pi-ai:
providers:
wb2api:
api: openai-completions
baseURL: https://api.example.com/v1
models:
- id: global:deepseek-v4.1-flash
compat:
thinkingFormat: deepseek
reasoningEfforts:
off:
high: high
max: max
三个细节:
thinkingFormat: deepseek让off真的发送thinking: {type: disabled}——否则默认会思考的模型停不下来。- 只留空
off:是合法的;其他等级必须给值,值就是协议上reasoning_effort的写法。 - 冒号后什么都不写(例如
supportsDeveloperRole:)会被拒绝,不是被忽略。
6.5 兼容性实测结果
用真实请求逐项验证过,结论是不需要额外兼容开关:
| 测试项 | 结果 |
|---|---|
POST /v1/chat/completions 普通对话 |
✅ 正常返回 |
role: "developer" 系统提示词 |
✅ 接受 |
max_completion_tokens 字段 |
✅ 接受 |
| 工具调用 / function calling | ✅ 返回 finish_reason: "tool_calls" |
| 流式 SSE | ✅ 逐块推送,含思维链增量 |
GET /v1/models |
✅ 返回完整模型清单 |
后两项是重点。dsh 对推理模型默认会以 role: "developer" 发送系统提示词、用
max_completion_tokens 表示输出上限,多数网关会拒这两个写法。本网关两样都接受,
所以 compat.supportsDeveloperRole 和 compat.maxTokensField 都不用配。
6.6 其他客户端
同一个网关也能直接喂给 Cursor、Cline、Roo、Continue、Codex CLI 等:
Base URL: https://api.example.com/v1
API Key: <第 1.2 步生成的密钥>
Model: global:deepseek-v4.1-flash (或清单里的任意一个)

评论(0)
暂无评论