内网部署面板如何接收 GitHub 的自动部署通知
面板只监听内网 IP,又不想把管理界面暴露出去——怎么让 GitHub 在 push 之后触发一次部署。
结论先放前面:只放行一个需要 Bearer token 的 POST 端点就够了。四个坑全是「配置都对,但被更早生效的东西拦住了」。
环境是 NAS 上的 Coolify + Cloudflare Tunnel。
一、先问清楚值不值得做
在动手之前值得停一下。自动部署省下的是「构建完成后手动点一次 Deploy」这个动作,代价是:
- 一条常驻的公网入口
- 一套需要长期维护正确性的规则(隧道路由、WAF、token 有效期)
- 每个仓库两个 Secret
如果你的应用大多是直接在面板粘贴 compose、一周才改几次,手动点更省心。本文的前提是:你已经有多个走 Git 仓库部署的应用,且改动频繁到手动部署成为负担。
面板本身的用法(网络别名、健康检查、compose 写法)见在 NAS 上用 Coolify 部署的十条约定。
二、三种方案
| 方案 | 原理 | 代价 |
|---|---|---|
| A. 隧道只放行部署接口 | 给面板域名加一条隧道路由,Path 限定为部署 API,其余路径不可达 | 一个端点暴露在公网,需维护隧道与 WAF 规则 |
| B. 自托管 runner | GitHub Actions 跑在你自己的机器上,直接访问内网 | 要维护 runner,占用 NAS 资源 |
| C. 轮询 | NAS 上定时查镜像仓库的 digest,变了就调本地 API | 零暴露,但有延迟,且要自己写脚本 |
为什么走 A
本文走 A。它的关键在于暴露面可以做得很小:只有一个需要 Bearer token 的 POST 端点,管理界面在公网上根本不存在。
如果对暴露面有更严格的要求,C 是彻底的答案——全程 NAS 主动出站,不需要任何入口。
三、动手:六步搭通
第一步 拿到部署接口的地址
Coolify 每个应用的 Webhooks 页面有一个 Deploy webhook URL,形如:
http://<面板地址>/api/v1/deploy?uuid=<应用uuid>&force=false记下这个 uuid,后面要用。
第二步 建 API Token
面板 → Keys & Tokens → 新建:
- 权限只勾 Deploy,这是最小权限
- 有效期设成最长或 Never
有效期这一项容易踩:默认 30 天,到期后 Actions 会静默失败——curl 返回 401,但没人会去看每次构建的日志。
第三步 本地先验证接口
在内网直接测,排除面板侧的问题:
curl -s -X POST "http://<内网IP>:8000/api/v1/deploy?uuid=<uuid>&force=true" \ -H "Authorization: Bearer <token>"成功返回:
{"deployments":[{"message":"Service xxx started. It could take a while, be patient.","resource_uuid":"..."}]}两个容易撞上的响应:
{"message":"This endpoint has changed to a POST request."}— 漏了-X POST{"message":"Unauthenticated."}— 没带 Authorization 头,或 token 错
第四步 隧道只放行这一个路径
Cloudflare Zero Trust → Networks → Tunnels → 选中隧道 → Public Hostname,新增一条:
| 字段 | 值 |
|---|---|
| Subdomain | coolify |
| Domain | 你的域名 |
| Path | api/v1/deploy |
| Service | http://<内网IP>:8000 |
这里有本文第一个坑,见第四节。
配好后验证两件事:
# 管理界面应该打不开curl -I https://coolify.example.com/# 期望:404
# 部署接口应该能通curl -s -X POST "https://coolify.example.com/api/v1/deploy?uuid=<uuid>&force=true" \ -H "Authorization: Bearer <token>"# 期望:Service xxx started第五步 在构建 workflow 末尾加一步
关键点:不要写成独立的 workflow 用 push 触发。那样部署会和镜像构建并行,Coolify 拉到的还是旧镜像。
正确做法是加在构建 workflow 的最后一个 job 末尾:
# 镜像 manifest 合成完毕后,通知面板拉新镜像重新部署。 # if 条件与上面 latest tag 的 enable 条件保持一致: # 只有真正刷新了 :latest 的构建才触发部署,工作分支的镜像不动 latest。 - name: Trigger deploy if: github.ref_type == 'tag' || github.ref == 'refs/heads/main' run: | response=$(curl -s -w "\n%{http_code}" -X POST \ "https://coolify.example.com/api/v1/deploy?uuid=${{ secrets.COOLIFY_UUID }}&force=true" \ -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}") body=$(echo "$response" | head -n -1) code=$(echo "$response" | tail -n 1) echo "HTTP $code" echo "$body" if [ "$code" != "200" ]; then echo "::error::部署触发失败" exit 1 fi echo "::notice::部署已排队"force=true 是必需的:镜像 tag 固定为 latest 时,不强制的话面板会认为配置没变化而跳过部署。
第六步 建 Secret
仓库 Settings → Secrets and variables → Actions:
COOLIFY_TOKEN— 所有仓库可共用,也可以建在组织级COOLIFY_UUID— 每个仓库对应的应用不同,各自建
六步做完,push 一次就该自动部署了。 下面四个坑是我实际撞上的,链路不通时对着找。
四、坑一:隧道 ingress 的匹配顺序
隧道进来之后那一段(Traefik 路由、证书、中间件)单独写过一篇网关 HTTPS 的分层排查。
配好路径规则后,两条 curl 全部返回 404,而且是 404 page not found 这种纯文本——这是 cloudflared 自己返回的,说明请求到了隧道但没匹配到任何规则。
看 cloudflared 的日志能直接定位:
docker logs <cloudflared容器> --tail 40日志里会打印当前的 ingress 配置:
{"ingress":[ {"hostname":"*.example.com", "service":"http://traefik:8888"}, {"hostname":"coolify.example.com", "path":"api/v1/deploy", "service":"http://192.168.x.x:8000"}, {"service":"http_status:404"}]}问题一目了然:通配符规则排在前面。cloudflared 的 ingress 是按顺序匹配、第一条命中即止,coolify.example.com 先被 *.example.com 吃掉,转给了反向代理,而代理上没有这个域名的路由,于是 404。
修法:把具体规则拖到通配符之前
在 Public Hostname 列表里调整顺序:
{"ingress":[ {"hostname":"coolify.example.com", "path":"api/v1/deploy", "service":"http://192.168.x.x:8000"}, {"hostname":"*.example.com", "service":"http://traefik:8888"}, {"service":"http_status:404"}]}这条规则要记住:凡是新增具体 hostname,都必须排在通配符之前。
五、坑二:workflow_run 只在默认分支生效
最初的方案是写一个独立的 deploy.yml,用 workflow_run 监听构建 workflow 的完成事件:
on: workflow_run: workflows: ["Build and Push Docker Image"] types: [completed]文件路径正确、语法正确、push 成功,但 Actions 页面左侧的 workflow 列表里始终不出现这个 workflow,手动触发的按钮也无从点起。
原因是 GitHub Actions 的一条硬性规则:workflow_run 和 schedule 类型的触发器,只有当 workflow 文件存在于仓库的默认分支时才生效。
如果你的开发分支不是默认分支(比如长期在一个功能分支上迭代),这个 workflow 永远不会被注册。
两条出路
- 把文件合并到默认分支
- 放弃独立 workflow,把触发步骤并进构建 workflow 的最后一步
选 2 更好,除了绕开这个限制,还顺带解决了时序问题——步骤在同一个 job 里顺序执行,构建完成才轮到部署,不需要任何跨 workflow 的协调。
六、坑三:Cloudflare 的机器人挑战拦住了 GitHub runner
链路打通后,本地 curl 能成功,GitHub Actions 却返回 403,响应体是一整页 HTML:
<title>Just a moment...</title>...<span id="challenge-error-text">Enable JavaScript and cookies to continue</span>这是 Cloudflare 的 Managed Challenge。GitHub runner 的 IP 被判定为自动化流量,要求执行 JS 验证——curl 当然做不到。
本地能通、CI 不能通,这个差异本身就指向了来源 IP 的判定。
WAF 自定义规则管不到它
第一反应是加 WAF 自定义规则跳过:
(http.host eq "coolify.example.com" and starts_with(http.request.uri.path, "/api/v1/deploy"))Action 选 Skip,把所有 WAF 组件都勾上。但改完仍然 403。
原因在响应里的 cType: 'managed' 和一个已知行为:免费版的 Bot Fight Mode 执行在 WAF 自定义规则之前,Skip 管不到它。
解法是直接关掉 Bot Fight Mode:Security → Settings → Bot Fight 模式 → 关闭。
关掉它安全吗
取决于你的服务构成。如果域名下的服务要么套了统一登录、要么只在内网可达、要么本身就是公开的,那么 Bot Fight Mode 提供的保护本就有限。
它拦的是「看起来像脚本」的请求:真正的恶意扫描器会伪装 UA 绕过,而老实的自动化工具(比如你的 CI)反倒被拦。
关掉之后,DDoS 防护、托管规则集、浏览器完整性检查、自定义规则都还在。
那条 WAF 自定义规则不用删——Bot Fight Mode 只是拦截来源之一,这条规则继续为这个路径跳过其余组件。
七、坑四:Secret 没建,变量为空
关掉 Bot Fight Mode 后,403 变成了:
HTTP 404{"message":"No resources found."}这是好消息——请求终于打到了面板,是面板说找不到资源。
原因是 COOLIFY_UUID 这个 Secret 还没建。GitHub Actions 对未定义的 secret 不报错,直接替换成空字符串,URL 变成 uuid=&force=true,面板自然找不到对应的应用。
八、故障速查:靠响应码认位置
这条链路上每一环的失败都有独特的响应特征,不用猜:
| 响应 | 卡在哪 |
|---|---|
404 page not found(纯文本) | 隧道没匹配到规则 |
403 + HTML 挑战页 | Cloudflare 拦截 |
401 Unauthenticated | token 错或没传 |
404 {"message":"No resources found."} | uuid 错或为空 |
200 + Service xxx started | 成功 |
九、验证整条链路
Secret 建好后重跑,逐层确认:
1. Actions 日志
HTTP 200{"deployments":[{"message":"Service xxx started. It could take a while, be patient.","resource_uuid":"..."}]}注意这只说明触发成功,部署本身还在跑。
2. 面板
应用页 → Deployments,最上面一条的状态从 In progress 变成 Finished。
3. 容器
docker ps --format '{{.Names}}\t{{.Status}}' | grep <应用名>Up 时间重新从零计时,且最终是 (healthy),说明容器确实被重建了。
4. 打开网站
这次改的东西生效了没有——最终验收只能靠这一步。
十、小结
整条链路的形态:
push → 构建镜像并推到 registry → 同一 job 的最后一步 curl 部署接口 → Cloudflare 边缘(WAF 规则放行)→ 隧道(路径匹配) → 内网面板 → 拉新镜像 → 容器重建四个坑归纳起来是同一类问题:配置本身都是对的,但被某个「更早生效的东西」拦在了前面。
通配符 ingress 排在具体规则之前、Bot Fight Mode 执行在 WAF 之前、workflow_run 的注册发生在默认分支上、空 Secret 在请求发出前就被替换掉了。
排查这类问题,比起反复检查配置本身,更有效的是沿着请求路径逐段验证,看它到底走到了哪一步就停下了。每一环的失败都有自己的响应特征,认识这些特征比任何配置检查清单都快。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!


