Webhook 的回调必须落在公网可达的 URL 上,可本地开发机没有公网地址,GitHub 的回调请求根本送不进来。最初我把服务临时部署到一台 VPS 上调试,结果每次改代码都要重新构建、上传、重启,修一个小 bug 都得来回折腾十来分钟。后来我转向内网穿透,把 ngrok、Cloudflare Tunnel、frp、Tailscale Funnel 四种方案都试了一遍,最终选了 Cloudflare Tunnel 作为长期方案、ngrok 应急备用。选它的原因很直接:子域名稳定不随机、免费额度足够开发、不占额外机器,还自带一层鉴权能力。下面把接入过程和踩过的坑完整过一遍。
一、为什么需要内网穿透
翻译工具的核心链路是:GitHub 仓库有 push 时触发翻译任务,靠的是 Webhook。Webhook 的本质是 GitHub 主动向你的服务发起回调,你的服务必须有一个公网可达的 URL,事件才送得进来。本地机器没有公网地址,这个前提天然不成立。
- Webhook 触发依赖公网回调 URL;
- OAuth 回调 URL 同样要公网可达;
- 本地数据库、缓存、模型都跑在 dev 机器。
一开始我以为把回调地址都改成 VPS 域名就行,结果一连串问题冒出来:环境变量和本地不一致、回调写进临时域名又怕污染数据、每次验证都要走完整套部署流程。折腾两天后我悟到一件事:本地开发要的是”和线上等价的能力”,不是”和线上等价的环境”。内网穿透只解决前者——让公网访问到本地端口,其余逻辑照旧。这也是为什么项目里的数据库、模型推理、任务队列全部留在 dev 机器上,只有流量入口走穿透隧道。
二、方案对比
选型时我把主流方案逐个试过,各有利弊。下面从配置成本、域名稳定性、是否依赖额外机器三个维度对比。
| 方案 | 优点 | 缺点 |
|---|---|---|
| ngrok | 零配置、随机域名 | 免费版域名不固定 |
| Cloudflare Tunnel | 稳定子域名、免费、鉴权 | 配置稍多 |
| frp | 自建可控 | 需要公网 VPS |
| Tailscale Funnel | 私有组网 | 团队成员都要加入 |
ngrok 上手门槛低,一条命令就有 URL,但免费版域名每次重启都变,GitHub App 里配置的 Webhook 地址得跟着改,开发节奏被频繁打断。frp 适合手里有 VPS 的情况,控制力强,但多一台机器多一份维护。Tailscale Funnel 适合小团队内部预览,跨团队接入成本偏高。Cloudflare Tunnel 配置量居中,换来的是域名固定和免费额度,对长期开发更划算,这也是我最终选它的原因。
三、Cloudflare Tunnel 配置
配置步骤比预想的多几步,但每一步目的明确,照顺序走基本不会卡。
3.1 安装
先装 cloudflared 客户端,macOS 直接走 Homebrew。
brew install cloudflared # macOS
# 或下载二进制
这段解决”没有客户端没法建隧道”的问题。装完先跑一遍 cloudflared --version 确认版本正常,免得后面报 command not found。Linux、Windows 也都有对应安装包,二进制方式更省事。
3.2 登录
cloudflared tunnel login
浏览器会跳转到 Cloudflare 授权页,选择子域名授权。
登录要授权到 Cloudflare 账号,页面会列出你名下的域名。我在这步卡过一次:授权完没立刻生效,等了约一分钟 DNS 才解析正常。所以登录后别急着建隧道,稍等一下再往下走。
3.3 创建隧道
cloudflared tunnel create docs-translator
记录返回的隧道 ID 和凭证文件路径。
创建命令会生成一个以隧道 UUID 命名的 JSON 凭证文件,相当于隧道的”钥匙”,启动和配置都要用它。建议把路径记下来或备份一份,丢了就得重建隧道,代价不小。
3.4 配置
配置文件在 ~/.cloudflared/config.yml,核心是 ingress 规则。
tunnel: docs-translator
credentials-file: /path/to/<UUID>.json
ingress:
- hostname: dev.docs-translator.example.com
service: http://localhost:3000
- service: http_status:404
这段解决”公网域名转发到本地哪个端口”的映射问题。容易踩的坑是 ingress 顺序:最后一条必须是指向 http_status:404 的兜底规则,否则匹配不到 hostname 的请求会直接报配置错误。另外 credentials-file 要写绝对路径,相对路径在部分环境会解析失败。
3.5 DNS 解析
cloudflared tunnel route dns docs-translator dev.docs-translator.example.com
这条命令把子域名解析到隧道,省去手动去 DNS 面板加 CNAME 的步骤。如果提示域名被占用,去 Cloudflare DNS 面板查历史记录,删掉旧的再执行。
3.6 启动
cloudflared tunnel run docs-translator
启动后 dev.docs-translator.example.com 即指向本地 3000 端口。
建议前台先跑一次,确认日志里出现 Registered tunnel connection,再考虑放后台或做成服务。我当时直接放后台,结果端口没起来,排查半天才发现是服务本身挂了,和隧道无关。验证顺序应该是:本地 curl 通 → 隧道日志正常 → 再走公网域名。
四、在 GitHub App 中配置
隧道通了之后,把 GitHub App 的回调和 Webhook URL 换成穿透域名,改动集中在三处。
| 项目 | URL |
|---|---|
| Webhook URL | https://dev.docs-translator.example.com/api/webhook |
| OAuth 回调 | https://dev.docs-translator.example.com/api/auth/callback/github |
| User agent | 不限制 |
改完记得手动触发一次测试推送,去 App 设置的 “Recent Deliveries” 里看是否返回 200。OAuth 回调地址要和代码里 NextAuth 配置的 callback 路径严格一致,路径差一个斜杠都会导致授权失败,这类问题页面报错很笼统,只能看服务端日志定位。
五、安全加固
穿透等于把内网口子开到了公网,安全不能省。我给项目做了四层加固。
- Webhook 路径加 secret 校验;
- Tunnel 开启 Cloudflare Access,限制邮箱或国家;
- 本地服务只监听 127.0.0.1,避免裸暴露;
- 关闭本地管理面板的公网访问。
其中 Cloudflare Access 是我踩坑后补上的。最开始没开,公开域名挂了两天,被不明来源的请求扫了几百次,虽然全是 404,日志看着却很不安心。开启 Access 后,非授权请求直接由 Cloudflare 拦截,连后端都不沾。Webhook 的 secret 校验也要注意:GitHub App 里配置的 secret 必须和代码端做 HMAC 校验时用的一致,两边不一致会静默失败,很难排查。
六、与生产环境的关系
穿透只是开发期的临时手段,和生产严格分离。
- Tunnel 仅用于本地开发;
- 生产用 Cloudflare 或 Vercel 部署,回调走正式域名;
- 切换域名时只改环境变量,不动业务代码。
这也是我坚持”域名逻辑全部环境变量化”的原因。开发用 dev 域名,上线切生产域名,只改 env 不改代码,避免回调地址写死在代码里。多环境切换不出岔子,靠的就是这层抽象。
七、常见问题排查
接入过程中踩过的坑整理成一张排查表,遇到现象直接对号入座。
| 现象 | 排查 |
|---|---|
| 域名不可达 | 检查 DNS 是否生效 |
| 本地 502 | 检查服务端口、tunnel 进程 |
| Webhook 未触发 | 看 GitHub App 的”Recent Deliveries” |
| 回调 401 | 检查 NextAuth.js v5 中 trustedHost |
常被忽略的是最后一行:NextAuth.js v5 默认校验请求的 Host 头,穿透域名不在 trustedHost 白名单时,回调会稳定返回 401,页面报错却很笼统,不看服务端日志根本定位不到。把 dev 域名加进 trustedHost 后立刻恢复。
八、自动启动(开发提效)
手动敲隧道命令很容易忘,我把它并进项目脚本,一条命令同时起开发服务和隧道。
{
"scripts": {
"tunnel": "cloudflared tunnel run docs-translator",
"dev:tunnel": "concurrently \"pnpm dev\" \"pnpm tunnel\""
}
}
这段把两条命令收进一个入口,concurrently 会把 dev 服务和隧道日志混在同一个终端,Webhook 有没有进来一眼就能看到。注意退出时要让两个进程都能正常收尾,脚本里处理了 SIGINT,避免隧道进程残留占着端口。
九、给团队的建议
如果团队多人协作,下面几条建议能把接入成本压得很低。
- 每人独立子域名(
alice.dev.docs-translator.example.com); - Tunnel 与 GitHub App 配置分离;
- 写一份 onboarding 文档,新人 5 分钟内接入。
每人独立子域名很关键:共享一个域名的话,一个人开着隧道,另一个人再启动就会冲突,日志里的报错还很难看懂。Tunnel 配置和 GitHub App 配置分离,意思是 App 只配一个固定的回调地址,隧道侧自由调整,两者解耦后互不干扰。
十、退出策略
开发完成后别把隧道留着,该清理就清理。
cloudflared tunnel delete docs-translator
cloudflared tunnel route dns delete docs-translator dev.docs-translator.example.com
第一条删隧道,第二条删 DNS 记录。都执行完再回 Cloudflare 面板确认没有残留,避免子域名长期挂在公网上被扫描。如果只是暂时不用,也可以只停进程不删隧道,下次直接 tunnel run 就能恢复。
到这里,整个内网穿透的接入链路就完整了:选型、配置、对接 GitHub App、安全加固,再到退出清理,每步都有对应的操作和避坑点。
常见问题(FAQ)
Q1:Tunnel 安全吗?
Cloudflare Tunnel 是加密双向通道,比 ngrok 长期暴露更安全。
Q2:可以用自己的域名吗?
可以,把 NS 切到 Cloudflare 或用 CNAME。
Q3:Tunnel 限速吗?
Cloudflare 免费版有连接数限制,但本地开发足够。