自建 Traefik 网关:HTTPS 打不开的完整排查路径
自建网关的 HTTPS 打不开,一次定位到五个层面的问题。环境是 NAS 上的 Coolify + Traefik,三条访问路径:内网直连、Cloudflare 隧道、VPS frp 透传。
结论先放前面:「打不开」包含至少五种完全不同的故障,必须分层验证。下面是从内到外的排查顺序和每个坑,四份 compose 附在文末。
一、环境与架构
硬件与角色
| 角色 | 说明 |
|---|---|
| NAS | 家里的主机,固定内网 IP,跑 Docker 和所有业务容器 |
| 境外 VPS | 跑 3x-ui,提供 VLESS-REALITY 出站代理(这台是怎么搭的) |
| 国内 VPS | 跑 frps,提供国内低延迟的公网入口 |
| Cloudflare | 托管域名,提供 DNS、Tunnel、证书签发的 DNS-01 验证 |
三条请求路径
内网访问:浏览器 → DNS 解析到 NAS 内网 IP → Traefik :443 → 服务容器隧道访问:浏览器 → Cloudflare 边缘 → cloudflared 容器 → Traefik :8888 → tinyauth → 服务容器frp 访问:浏览器 → 国内 VPS :8443 → frpc 容器 → Traefik :443 → tinyauth → 服务容器三条路径共用同一个 Traefik 和同一批服务容器,但入口不同、证书来源不同、中间件不同。「打不开」发生在哪条路径上,排查方向完全不一样。
一个常被忽略的点:部署面板(本文是 Coolify)不在流量链路上。它只负责起停容器,停掉面板网站照常跑。把控制面和数据面分开看,能少走很多弯路。
二、分层验证:五条命令定位问题
配置都交代完了,现在进入排查。按这个顺序跑,第一条失败的就是问题所在。
第一层:容器自己活着吗
从 Traefik 容器内部访问目标服务,绕开所有路由和证书:
docker exec coolify-proxy wget -qO- --timeout=5 http://<服务名>:<端口>/ping有响应说明容器和 Docker 网络都正常。这一步失败就去看容器日志,不用管 Traefik。
注意这里用的是服务名而不是容器名,原因见坑五。
第二层:Traefik 认这个域名吗
用 Host 头直接问入口,绕开 DNS 和证书:
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: 你的域名" http://<NAS内网IP>:8888/| 返回 | 含义 |
|---|---|
| 200 / 302 / 401 | 路由和中间件都正常,问题在 DNS 或客户端 |
| 404 | Traefik 没认出这条路由,去查 labels |
| 502 | 路由认了但连不上后端,查网络和端口 |
| 000 | 入口没起来,查端口绑定 |
401 是好消息,说明认证中间件正在正常工作。
第三层:HTTPS 证书签下来了吗
access log 会淹没一切,先过滤掉:
docker logs coolify-proxy 2>&1 | grep -v '"RequestAddr"' | grep -i -E "acme|lego|certificate" | tail -30关键词 Server responded with a certificate 表示成功。
直接看证书库里有哪些域名(acme.json 是压缩的单行 JSON,grep 抓不到,要用 json 解析):
cat /data/coolify/proxy/acme.json | python3 -c "import sys,json; d=json.load(sys.stdin); print([c['domain']['main'] for r in d.values() for c in (r.get('Certificates') or [])])"第四层:DNS 解析到哪
nslookup 你的域名在 NAS 上和在客户端上各跑一次,结果可能不同。
第五层:客户端能到吗
在客户端(不是服务器)上:
curl -v https://你的域名/ --max-time 10curl 通但浏览器不通,是一个极强的信号,说明网络层没问题,问题在浏览器这一层。原因见坑三。
三、坑一:Cloudflare API Token 权限不全
现象:所有域名都签不到证书,日志报
[status code 403] 9109: Invalid access tokencloudflare: failed to find zone example.com.紧接着变成
[status code 429] 10502: Too many authentication failures429 是结果不是原因。反复用无效 token 重试触发了 Cloudflare 的限流,看到 429 时要往前翻日志找第一个错误。
根因是 token 权限少了一条。DNS-01 challenge 需要两个权限:
区域 → DNS → 编辑(写 TXT 记录)区域 → 区域 → 读取(先查出 zone id)
只给 DNS:Edit 会卡在第一步,报「找不到 zone」而不是「没有权限」,非常容易误导。创建 token 时权限区要有两行,区域资源限定到具体域名。
修复后还要清空证书库,否则失败记录会拖着:
rm -f /data/coolify/proxy/acme.json && touch /data/coolify/proxy/acme.json && chmod 600 /data/coolify/proxy/acme.json然后重启 Traefik。如果还在 429 封禁期内,等 10 到 15 分钟再重启即可,不用改配置。
四、坑二:端口绑定与端口占用
容器起不来,报
Error starting userland proxy: listen tcp4 0.0.0.0:8080: bind: address already in use这是把 192.168.1.10:8080:8080 改成 8080:8080 引起的。宿主机上已有服务占用了 8080,只是它绑在另一个地址上,所以绑定具体 IP 时不冲突,改成全网卡就撞了。
查占用者:
ss -tlnp | grep :8080两种写法的取舍:
| 写法 | 好处 | 代价 |
|---|---|---|
IP:端口:端口 | 避免端口冲突,不对其他网卡暴露 | 机器 IP 变化时容器起不来;本机 127.0.0.1 访问不到 |
端口:端口 | IP 变化无影响 | 容易和宿主机服务冲突 |
家用 NAS 上 IP 一般固定,绑 IP 更安全。但要确认路由器上做了静态 IP 或 MAC 绑定,否则 DHCP 换地址会导致整个网关起不来。
五、坑三:客户端代理劫持内网域名
最隐蔽的一个。服务端一切正常,curl 也通,唯独浏览器打不开。
原因:代理软件(v2rayN / Clash 等)把自有域名的请求送到了境外出口,境外服务器当然连不上你家的内网 IP。
为什么 curl 通而浏览器不通?curl 不读系统代理设置,浏览器读。这个差异本身就是诊断依据。
按客户端改例外
修复方式因客户端而异:
| 客户端 | 改哪里 | 追加什么 |
|---|---|---|
| v2rayN | 设置 → 系统代理设置 → 例外列表末尾 | ;*.example.com |
| Clash 系 | 配置文件 rules: 段最前面 | - DOMAIN-SUFFIX,example.com,DIRECT |
注意两处语法不同:系统代理例外用 *.域名,Clash 规则用 DOMAIN-SUFFIX,域名。用通配符一劳永逸,以后新增子域名不用再改。
Tun 模式是个例外:它在网卡层接管全部流量,系统代理的例外列表不生效,需要另外在路由规则里排除。
有没有一劳永逸的办法
想彻底摆脱客户端配置,唯一的办法是在路由器的 DNS 层做重写,把自有域名解析到内网 IP,这样全家所有设备自动直连。
但只要客户端开着 Tun 或 fake-ip,仍会绕过路由器 DNS——不存在 100% 免配置的方案。
六、坑四:浏览器缓存了失败的证书状态
证书签好了,服务端 curl 干净通过,浏览器仍然标红「不安全」——HTTPS 就是打不开。
原因是浏览器在证书还没签好时访问过,缓存了坏证书和 HSTS 状态。
清理方法(Chrome / Edge),地址栏输入:
chrome://net-internals/#hsts- 拉到最下面的 Delete domain security policies
- 输入域名,点 Delete
- 关掉所有该站点的标签页再重开
快速判定:开无痕窗口试一次。能开就 100% 是缓存问题。
七、坑五:容器名被面板改写
Coolify 这类面板部署 compose 时会给容器名加 uuid 后缀(这条和另外九条一起写在在 NAS 上用 Coolify 部署的十条约定里):
tinyauth-a1b2c3d4e5f6g7h8这会让人担心 http://tinyauth:3000 这类容器名互访失效。实际上不会——面板会把 compose 里的服务名注册成网络别名:
docker inspect <容器> --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}: {{$v.Aliases}}{{"\n"}}{{end}}'输出里能看到 [tinyauth-a1b2c3d4e5f6g7h8 tinyauth],别名还在。
要记住的规则:网络别名来自 compose 里 services: 下面那个 key,不是 container_name。 所以凡是需要被其他容器按名字访问的服务,服务名必须写成你要用的那个名字,两者保持一致最省心。这也是为什么第二节第一层验证用服务名而不是容器名。
八、坑六:IP 白名单中间件与反向链路
中间件的适用范围:
| 中间件 | 内网 443 | 隧道 / frp | 说明 |
|---|---|---|---|
| web-default | 可以 | 可以 | 基础链,安全头 + 压缩;含 frameDeny,需要 iframe 嵌入的服务要另写 |
| internal-only | 可以 | 不行 | 隧道流量源 IP 是 cloudflared 容器,挂了等于全放行 |
| rate-limit | 不行 | 可以 | 内网没有 XFF,会把所有请求算作同一来源 |
| retry | 可以 | 可以 | POST 接口慎用,会重复提交 |
挂错位置就是 403
如果内网路由挂了 internal-only,而流量是从 frp 或隧道进来的,会被直接拒绝——因为源 IP 是 frpc / cloudflared 容器的 IP,不是真实客户端 IP。
这个坑在「主域名 CNAME 到加速域名」的场景下必然踩到:访问 https://主域名:8443 时 Host 头是主域名,Traefik 匹配到 <app>-lan 那条,而它挂着 internal-only,直接 403。
同一域名两套策略:改用 ClientIP
如果同一个域名既要内网免登录又要外网套登录,白名单中间件解决不了,要改用路由匹配阶段的 ClientIP 条件配合 priority:
# 内网来源优先匹配,免登录- traefik.http.routers.app-lan.rule=Host(`app.example.com`) && ClientIP(`192.168.0.0/16`)- traefik.http.routers.app-lan.priority=100- traefik.http.routers.app-lan.middlewares=web-default@docker
# 其余落到这条,套登录- traefik.http.routers.app-ext.rule=Host(`app.example.com`) || Host(`fast.example.com`)- traefik.http.routers.app-ext.priority=1- traefik.http.routers.app-ext.middlewares=web-default@docker,tinyauth@docker区别在于:ClientIP 作用在路由匹配阶段,不匹配就落到下一条;白名单中间件作用在匹配之后,不通过就直接 403,没有退路。
九、附带发现:假的健康检查
排查过程中发现一个长期存在但从没暴露的问题——cloudflared 的健康检查原本写成:
healthcheck: test: ["CMD", "cloudflared", "--version"]这只检查二进制文件存在,隧道断了照样显示 healthy。正确写法见附录的 cloudflared 配置,用它自带的 metrics ready 接口。
同类问题值得自查:健康检查探的是服务真实可用性,还是只探了进程存在?代理类容器应该通过自己探一次外网,Web 服务应该请求真实的健康接口。
十、一份可复用的排查清单
遇到「网站打不开」,按顺序回答这几个问题:
- 浏览器具体报什么? 超时 / 证书警告 / 502 / 404 / 白屏——这一句往往能省掉一半工作
- 容器活着吗? →
docker exec从网关内部探服务 - 网关认这个域名吗? →
curl -H "Host:"打入口 - 证书签下来了吗? → 过滤掉 access log 看 acme 日志
- DNS 解析到哪? → 服务器和客户端各查一次
- 客户端能到吗? → 客户端上 curl
- curl 通但浏览器不通? → 代理例外、HSTS 缓存、浏览器扩展
最后一条经验:服务端 curl 返回 200 时,就该停止怀疑服务端了。这个信号非常可靠,继续在服务端翻配置只会浪费时间。
附录:完整配置文件
上面排查用到的环境就是下面这套。排查时不需要读完,卡在哪一层再回来看对应的那份。
入口与中间件约定
| 入口名 | 端口 | 用途 |
|---|---|---|
| web | 80 | 301 跳转到 websecure |
| websecure | 443 | 内网直连,以及 frp 透传进来的流量 |
| web-cf | 8888 | Cloudflare 隧道回源 |
路由命名约定:每个服务写两条 router 指向同一个 service,<app>-lan 走 websecure,<app>-cf 走 web-cf,走哪条由 DNS 记录决定(灰云 A 记录指 NAS 走 lan,橙云 CNAME 指隧道走 cf)。
网关:Traefik
这是整套架构的核心。中间件库集中声明在 Traefik 自己的 labels 里,其他项目以 名字@docker 引用。
name: coolify-proxynetworks: coolify: external: true traefik-net: external: trueservices: traefik: container_name: coolify-proxy image: traefik:v3.7 restart: unless-stopped extra_hosts: - host.docker.internal:host-gateway environment: - TZ=Asia/Shanghai - CF_DNS_API_TOKEN= # 必改:权限需 Zone:DNS:Edit + Zone:Zone:Read networks: - coolify - traefik-net ports: # 绑定内网 IP 而非全网卡:宿主机上 8080 常被占用,且避免对外暴露 - 192.168.1.10:80:80 - 192.168.1.10:443:443 - 192.168.1.10:8080:8080 - 192.168.1.10:8888:8888 healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:8080/ping || exit 1"] interval: 4s timeout: 2s retries: 5 logging: driver: json-file options: max-size: 10m max-file: "3" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - /data/coolify/proxy/:/traefik command: - --ping=true - --global.checknewversion=false - --global.sendanonymoususage=false - --log.level=INFO - --accesslog=true - --accesslog.format=json - --api.dashboard=true - --api.insecure=true - --providers.docker=true - --providers.docker.exposedbydefault=false - --providers.docker.network=traefik-net - --providers.file.directory=/traefik/dynamic/ - --providers.file.watch=true - --entrypoints.web.address=:80 - --entrypoints.web.http.redirections.entrypoint.to=websecure - --entrypoints.web.http.redirections.entrypoint.scheme=https - --entrypoints.websecure.address=:443 - --entrypoints.web-cf.address=:8888 - --entrypoints.web-cf.forwardedHeaders.trustedIPs=127.0.0.1/32,192.168.0.0/16,172.16.0.0/12,10.0.0.0/8 - --certificatesresolvers.cloudflare.acme.dnschallenge=true - --certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare # 指定公共 DNS 解析器:本地 DNS 被污染或缓存时,传播检查会一直失败 - --certificatesresolvers.cloudflare.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53 - --certificatesresolvers.cloudflare.acme.dnschallenge.delaybeforecheck=30 - --certificatesresolvers.cloudflare.acme.email= # 必改:真实可收信地址 - --certificatesresolvers.cloudflare.acme.storage=/traefik/acme.json labels: - traefik.enable=true # ---- secure-headers ---- - traefik.http.middlewares.secure-headers.headers.frameDeny=true - traefik.http.middlewares.secure-headers.headers.contentTypeNosniff=true - traefik.http.middlewares.secure-headers.headers.referrerPolicy=strict-origin-when-cross-origin - traefik.http.middlewares.secure-headers.headers.permissionsPolicy=camera=(), microphone=(), geolocation=() - traefik.http.middlewares.secure-headers.headers.stsSeconds=31536000 - traefik.http.middlewares.secure-headers.headers.stsIncludeSubdomains=true # ---- compress ---- - traefik.http.middlewares.compress.compress=true # ---- internal-only:仅内网来源,只能挂 websecure 路由 ---- - traefik.http.middlewares.internal-only.ipallowlist.sourcerange=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 # ---- rate-limit:按 XFF 第 1 跳判定,只给隧道路由用 ---- - traefik.http.middlewares.rate-limit.ratelimit.average=100 - traefik.http.middlewares.rate-limit.ratelimit.burst=200 - traefik.http.middlewares.rate-limit.ratelimit.sourcecriterion.ipstrategy.depth=1 # ---- retry ---- - traefik.http.middlewares.retry.retry.attempts=3 - traefik.http.middlewares.retry.retry.initialInterval=100ms # ---- cors-allow ---- - traefik.http.middlewares.cors-allow.headers.accessControlAllowOriginList=* - traefik.http.middlewares.cors-allow.headers.accessControlAllowHeaders=* - traefik.http.middlewares.cors-allow.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS - traefik.http.middlewares.cors-allow.headers.accessControlMaxAge=86400 # ---- no-cache ---- - traefik.http.middlewares.no-cache.headers.customResponseHeaders.Cache-Control=no-store # ---- 基础链:所有路由都挂 ---- - traefik.http.middlewares.web-default.chain.middlewares=secure-headers@docker,compress@docker入口层:cloudflared 与 frpc
Cloudflare 隧道
services: cloudflared: container_name: cloudflare-tunnel image: cloudflare/cloudflared:latest restart: unless-stopped command: - tunnel - --no-autoupdate - --protocol - http2 # UDP 常被运营商干扰时改用 http2,网络好可去掉两行用默认 quic - --metrics - 127.0.0.1:2000 - run environment: - TZ=Asia/Shanghai - TUNNEL_TOKEN= # 必改 healthcheck: # 探 metrics 的 ready 接口,不要用 --version:那只检查二进制存在,隧道断了照样 healthy test: ["CMD", "cloudflared", "tunnel", "--metrics", "127.0.0.1:2000", "ready"] interval: 30s timeout: 10s retries: 3 start_period: 15s logging: driver: json-file options: max-size: 10m max-file: "3" mem_limit: 256m networks: - traefik-net
networks: traefik-net: external: trueCloudflare 后台的 Public Hostname 回源地址统一写 http://coolify-proxy:8888,靠 traefik-net 的内置 DNS 解析容器名,不经宿主机端口。
frp 客户端
配置文件由启动脚本写出,不依赖外部挂载,整个服务一个文件搞定:
services: frpc: container_name: frpc image: snowdreamtech/frpc:0.71.0-alpine restart: unless-stopped environment: - TZ=Asia/Shanghai - FRPS_ADDR= # 必改:VPS 公网 IP - FRPS_PORT= # 必改:frps 的 bindPort - FRP_TOKEN= # 必改:与 frps 一致 entrypoint: - /bin/sh - -c command: - | set -e cat > /tmp/frpc.toml << EOF serverAddr = "$$FRPS_ADDR" serverPort = $$FRPS_PORT
auth.method = "token" auth.token = "$$FRP_TOKEN" transport.tls.enable = true
log.level = "info" log.maxDays = 3
webServer.addr = "127.0.0.1" webServer.port = 7400
[[proxies]] name = "traefik-https" type = "tcp" localIP = "coolify-proxy" localPort = 443 remotePort = 8443 EOF echo "config written, connecting to $$FRPS_ADDR:$$FRPS_PORT" exec frpc -c /tmp/frpc.toml healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:7400/healthz >/dev/null 2>&1 || nc -z 127.0.0.1 7400 || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 15s logging: driver: json-file options: max-size: 10m max-file: "3" mem_limit: 128m networks: - traefik-net labels: - traefik.enable=false
networks: traefik-net: external: true三个要点:
type = "tcp"是原样透传,TLS 在家里的 Traefik 终止,VPS 上零证书、看不到明文- 脚本里的
$$是 compose 转义,实际执行为单个$ - 必须用 8443 而非 443:国内 VPS 上未备案域名访问 80/443 会被接入层拦截,非标端口不在拦截范围
认证层:tinyauth
Traefik 的 ForwardAuth 后端,给公网入口套一层统一登录页。
services: tinyauth: container_name: tinyauth image: ghcr.io/steveiliop56/tinyauth:latest pull_policy: always user: 0:0 restart: unless-stopped environment: - TZ=Asia/Shanghai - TINYAUTH_APPURL=https:// # 必改:登录页地址 - TINYAUTH_AUTH_USERS= # 必改:用户名:bcrypt 哈希,哈希里的 $ 双写成 $$ # CIDR 要对齐:192.168.0.0/12 是错的,实际覆盖 192.160~192.175 # 172.16/12 是 Docker 容器网段,Traefik 从容器网络过来,必须包含 - TINYAUTH_AUTH_TRUSTEDPROXIES=192.168.0.0/16,172.16.0.0/12 - TINYAUTH_AUTH_SESSIONEXPIRY=604800 - TINYAUTH_UI_TITLE= # 必改 - TINYAUTH_UI_WARNINGSENABLED=false volumes: - ./data:/data healthcheck: test: ["CMD", "tinyauth", "healthcheck"] interval: 30s timeout: 5s retries: 3 start_period: 10s logging: driver: json-file options: max-size: 10m max-file: "3" networks: - traefik-net labels: - traefik.enable=true - traefik.docker.network=traefik-net - traefik.http.services.tinyauth.loadbalancer.server.port=3000 # ---- 全局中间件,其他项目以 tinyauth@docker 引用 ---- # 地址里的 tinyauth 是服务名(网络别名),不是容器名,见坑五 - traefik.http.middlewares.tinyauth.forwardauth.address=http://tinyauth:3000/api/auth/traefik - traefik.http.middlewares.tinyauth.forwardauth.authResponseHeaders=authorization - traefik.http.middlewares.tinyauth.forwardauth.trustForwardHeader=true - traefik.http.middlewares.tinyauth.forwardauth.maxResponseBodySize=8192 # ---- 登录页本身不套 tinyauth,否则死循环 ---- - traefik.http.routers.tinyauth.rule=Host(``) # 必改 - traefik.http.routers.tinyauth.entrypoints=web-cf - traefik.http.routers.tinyauth.middlewares=web-default@docker - traefik.http.routers.tinyauth.service=tinyauth - traefik.http.routers.tinyauth-lan.rule=Host(``) # 必改 - traefik.http.routers.tinyauth-lan.entrypoints=websecure - traefik.http.routers.tinyauth-lan.tls.certresolver=cloudflare - traefik.http.routers.tinyauth-lan.middlewares=web-default@docker - traefik.http.routers.tinyauth-lan.service=tinyauth
networks: traefik-net: external: true登录页最好走 Cloudflare 隧道而非 frp——因为 frp 那条带 8443 端口,而 ForwardAuth 返回的跳转地址 X-Tinyauth-Location 指向登录页域名,登录页必须在任何入口下都能到达。
session cookie 按主域名下发,端口不影响作用域,所以在隧道上登录完跳回 frp 端口的页面,cookie 依然有效。
业务服务的标准写法
一个典型服务,两条路由指向同一个 service:
services: myapp: container_name: myapp image: ghcr.io/example/myapp:latest restart: unless-stopped environment: - TZ=Asia/Shanghai volumes: - ./data:/data healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 20s logging: driver: json-file options: max-size: 10m max-file: "3" networks: - traefik-net labels: - traefik.enable=true # 容器同时在多个网络时,必须指定 Traefik 该用哪个,否则可能路由到错误的网卡 - traefik.docker.network=traefik-net - traefik.http.services.myapp.loadbalancer.server.port=8080 # ---- 内网直连:灰云 A 记录指 NAS ---- - traefik.http.routers.myapp-lan.rule=Host(``) # 必改 - traefik.http.routers.myapp-lan.entrypoints=websecure - traefik.http.routers.myapp-lan.tls.certresolver=cloudflare - traefik.http.routers.myapp-lan.middlewares=web-default@docker,internal-only@docker - traefik.http.routers.myapp-lan.service=myapp # ---- 公网隧道:橙云 CNAME 指隧道 ---- - traefik.http.routers.myapp-cf.rule=Host(``) # 必改 - traefik.http.routers.myapp-cf.entrypoints=web-cf - traefik.http.routers.myapp-cf.middlewares=web-default@docker,tinyauth@docker - traefik.http.routers.myapp-cf.service=myapp
networks: traefik-net: external: true三个容易写错的地方:
- Host 规则必须用反引号,不是双引号。
Host("x.com")在 Traefik v3 里解析失败,路由直接不生效 tls=true和tls.certresolver不要同时写,后者已隐含前者- 引用全局中间件要带
@docker后缀,本项目内定义的中间件同理
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!


