boxmoe_header_banner_img

Hello! 欢迎来到我的博客!

文章导读

从零部署 workbuddy2api 网关:Docker 编排、NPM 反代与 DeepSeek Harness 接入







从零部署 workbuddy2api 网关:Docker 编排、NPM 反代与 DeepSeek Harness 接入


从零部署 workbuddy2api 网关:Docker 编排、NPM 反代与 DeepSeek Harness 接入

独立预览版 · 正文内容与「粘贴版」完全一致 · 粘贴到 WordPress 时请只复制下方 <div class=”doc”> 整块

📅 最后更新:2026年9月21日  | 
🏷️ 标签:Docker, Nginx Proxy Manager, Let’s Encrypt, 反向代理, workbuddy2api, DeepSeek Harness, OpenAI 兼容

本文记录一套完整的自建 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.com203.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
📌 为什么要用静态 IP:
第 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

确认 authsdata 的属主是 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-admindocker.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,不要填 localhost127.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、挂上证书
✅ 整个过程几秒到几十秒。签发成功后回到 Proxy Hosts 列表,
该条目右侧会显示证书到期时间,访问 https:// 也是绿锁了。
⚠️ 千万不要在 Advanced 里手写 ACME 校验的 location。
网上(包括本博客的早期版本)有说法称「首次签发需要手动补
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
📌 DNS 还没生效怎么办:
在 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/,点「添加账号」,流程是两步:

  1. 选择区域:国内版 CN国际版 Global
  2. 点「生成授权链接」,面板会显示二维码和一段明文 URL。
📌 二维码只是 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
模型 见下
⚠️ Provider ID 一旦保存就是永久的:
请求、会话、凭据引用都用它,想改名只能新建一个再删旧的。
显示名称、地址、协议、密钥、模型都还能改。

6.3 拉取模型清单

点「获取可用模型」,dsh 会请求 GET /v1/models。本网关支持这个端点,能直接列出全部模型,在搜索框里勾选即可。

📌 模型 ID 的前缀是有意义的:
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: deepseekoff 真的发送 thinking: {type: disabled}——否则默认会思考的模型停不下来。
  • 只留空 off: 是合法的;其他等级必须给值,值就是协议上 reasoning_effort 的写法。
  • 冒号后什么都不写(例如 supportsDeveloperRole:会被拒绝,不是被忽略。
✅ 适配器在下一次请求时重新读取配置,改完不用重启 dsh。

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.supportsDeveloperRolecompat.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)

查看评论列表

暂无评论


发表评论

表情 颜文字
插入代码

访客信息

您的IP 216.73.217.112
归属地 查询中…
天气 查询中…