首页
/ Coolify 外部 TLS 下的 HTTP→HTTPS 重定向设计:破除 Cloudflare Tunnel 重定向死循环

Coolify 外部 TLS 下的 HTTP→HTTPS 重定向设计:破除 Cloudflare Tunnel 重定向死循环

2026-09-07 11:49:54作者:董灵辛Dennis

导读

当 Coolify 通过 Cloudflare Tunnel 的 "all-resource" 模式对外提供服务时,公网 HTTPS 请求实际是以 http://localhost:80 进入 Coolify 代理的;若资源域名同时以 https:// 存储且 Coolify 侧强制 HTTP→HTTPS 跳转,请求会在隧道入口反复打回,最终报 TOO_MANY_REDIRECTS。本设计文档方案引入可开关的 Redirect HTTP to HTTPS 控制项,使 Coolify 能够如实以 https:// 保存对外域名、把 TLS 终结与 80→443 跳转交给 Cloudflare 等上游反代处理,并统一覆盖普通应用与 Service 应用。读完本文,你将掌握该开关的数据模型、Traefik/Caddy 代理标签生成差异、UI 行为与自动化测试边界,可直接对照本仓库源码逐行验证。

问题背景:HTTP 入口上叠加强制 HTTPS 的恶性循环

在 Cloudflare Tunnel 的 all-resource 场景下,Coolify 的整个服务器由一个隧道统一接入。cloudflared 对公网终结 TLS 后,把解密流量转发给 Coolify 的 HTTP 入口(http://localhost:80)。此时若某个资源的域名在 Coolify 里被保存成 https://app.example.com,而 Coolify 又为该域名生成了 HTTP→HTTPS 重定向,就会形成如下死循环:

  1. 用户访问 https://app.example.com → Cloudflare 完成 TLS 终结;
  2. 流量以 http://app.example.com 打回 Coolify 的 :80 入口;
  3. Coolify 看到请求走的是 HTTP 路由,于是 301 跳转到 https://app.example.com
  4. 跳转结果再次回到 Cloudflare 公网入口 → 再次以 http:// 打进 :80……周而复始,直到浏览器报 TOO_MANY_REDIRECTS

旧文档给出的规避办法是“把域名存成 http://”,但这掩盖了真实公网地址,会引发连锁副作用:

  • 服务端生成的 Secure Cookie 语义错误(HTTPS 页面上的 http 源 URL 可能导致 cookie 判定异常);
  • OAuth 回调 URLcanonical link 等依赖真实 scheme 的机制被误导。

原有的旁路手段并不完整:普通应用虽可在 Advanced 设置里关闭强制 HTTPS(见 app/Livewire/Project/Application/Advanced.php),但该开关离域名配置很远、不易发现;而 Service 应用生成的代理配置则始终硬编码启用该跳转,根本没有关闭入口。

设计目标与明确的非目标

目标

  • https:// 准确存储对外可见的域名;
  • 让 Cloudflare 这类上游反代负责 HTTP→HTTPS 跳转与 TLS 终结;
  • 行为对普通应用与 Service 应用保持一致;
  • 默认不改变存量资源的既有安全行为;
  • 保持通用性:不绑定 Cloudflare,也不引入服务器级 all-resource 隧道模式。

明确不做的事(避免过度设计):

  • 不自动探测 Cloudflare;
  • 不新增服务器级 all-resource 隧道开关;
  • 不配置可信转发头网络(trusted forwarded-header networks);
  • 不取代 end-to-end origin TLS 的工作流;
  • 不改变新旧资源的默认跳转行为。

数据模型:Service 应用新增布尔列,普通应用复用旧设置

设计方案为 Service 应用service_applications 表)新增非空布尔列 is_force_https_enabled,默认 true,从而保证迁移后存量 Service 应用行为不变。对应的迁移文件已落地:

database/migrations/2026_08_17_000000_add_is_force_https_enabled_to_service_applications_table.php

public function up(): void
{
    Schema::table('service_applications', function (Blueprint $table) {
        $table->boolean('is_force_https_enabled')->default(true);
    });
}

public function down(): void
{
    Schema::table('service_applications', function (Blueprint $table) {
        $table->dropColumn('is_force_https_enabled');
    });
}

app/Models/ServiceApplication.php 中,该字段已进入 $fillable(第 35 行)、声明 $attributes 默认值 true(第 50 行)、并注册 boolean 强转(第 58 行),同时新增了与普通应用同名的访问器:

public function isForceHttpsEnabled(): bool
{
    return $this->is_force_https_enabled;
}

从源码结构看,Service 应用与普通应用因此统一了读取入口:代理标签生成代码无需关心资源类型,只要调用 isForceHttpsEnabled() 即可。

普通应用不新增任何列,继续沿用既有的 ApplicationSetting::is_force_https_enabled(见 app/Models/ApplicationSetting.php 中的 cast 与 fillable 声明、app/Models/Application.php 的读取方法),也不存储任何与 Cloudflare 相关的专有状态。

UI 行为:域名页的 Redirect HTTP to HTTPS 开关

当资源至少有一个 https:// 域名时,Domains 页面会展示名为 Redirect HTTP to HTTPS 的布尔开关:

  • 默认开启,帮助文本为:

    Disable this when HTTPS and redirects are handled by Cloudflare Tunnel or another reverse proxy that connects to Coolify over HTTP.

  • 处于 Cloudflare Tunnel 场景的用户配置 https://app.example.com 后关闭此开关;直接暴露资源的用户保持开启。

针对不同类型资源,开关的落点不同:

资源类型 开关绑定的存储位置 备注
普通应用 / Docker Compose 应用 既有 ApplicationSetting::is_force_https_enabled Advanced 页同名旧开关不再作为独立事实源,应删除或改为同一设置并换用更清晰的文案
Service 应用中的每个 application service 新增的 service_applications.is_force_https_enabled 每个 application service 各有一个开关
仅含数据库的 Service 条目 不展示该开关 数据库域名不涉及 HTTP 跳转中间件

UI 实现可在 Service 应用的 Domains 视图中看到该控件:label="Redirect HTTP to HTTPS" 并绑定 updateForceHttps 变更处理器(见 resources/views/livewire/project/service/domains.blade.php),对应 Livewire 交互位于 app/Livewire/Project/Service/Domains.phpapp/Livewire/Project/Application/Domains.php

纯 HTTP 资源不展示该开关,且隐藏它不会重置已存储的值。测试 tests/Feature/ServiceDomainsTest.php 同时覆盖了“存在 HTTPS 域名时渲染开关”“HTTP-only 资源不渲染开关”两个分支。

代理配置:域名 scheme 与跳转策略彻底解耦

设计核心原则是域名书写方式(scheme)与重定向策略相互独立

  • https:// 域名照常生成 HTTPS 路由/监听器;
  • 其 HTTP 路由/监听器始终一并生成
  • 开启跳转时,HTTP 路由挂上 HTTPS 重定向中间件;
  • 关闭跳转时,HTTP 路由直接转发到资源、不带该中间件。

两者都由 bootstrap/helpers/docker.php 的标签生成函数承接。下面分别看 Traefik 与 Caddy 两个代理的实现差异。

Traefik:HTTP 路由的中间件链按开关分支

fqdnLabelsForTraefik 接收 bool $is_force_https_enabled = false。对 https:// 域名,它总是:

  1. 先定义全局 redirect-to-https 中间件(第 610 行):traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
  2. 生成 HTTPS 路由器(entryPoints=https,开启 TLS、letsencrypt certresolver,第 697-768 行);
  3. 生成 HTTP 路由器(entryPoints=http),并在其中按开关决定中间件链(第 771-788 行):
if ($is_force_https_enabled) {
    $httpMiddlewares = collect([]);
    if ($is_noindex) {
        $httpMiddlewares->push($noindex_name);
    }
    $httpMiddlewares->push('redirect-to-https');
} else {
    $httpMiddlewares = $middlewares;
}
if ($httpMiddlewares->isNotEmpty()) {
    $labels->push("traefik.http.routers.{$http_label}.middlewares={$httpMiddlewares->join(',')}");
}

关键差异在于:关闭跳转后,HTTP 路由直接复用 HTTPS 路由已算好的 $middlewares(含 gzip、stripprefix、www/non-www、basic auth、noindex 及用户自定义 service labels 中解析出的中间件),唯独不再追加 redirect-to-https。这与“只移除跳转行为、其余中间件保留”的测试要求一一对应(参见 tests/Unit/FqdnLabelsNoindexTest.php)。

Caddy:仅暴露 http:// + https:// 双站点、跳转目标跟随开关

fqdnLabelsForCaddy 对 Caddy 的处理是改变监听站点声明:当域名是 https:// 且关闭强制跳转时,站点地址不再是单一的 https://host,而是同时监听两个 scheme(第 554-556 行):

if ($schema === 'https' && ! $is_force_https_enabled) {
    $siteAddress = "http://{$host}, https://{$host}";
}

同时,Caddy 中的 www / non-www 重定向目标也以开关驱动(第 588-593 行):$redirect_schema = $is_force_https_enabled ? $schema : '{scheme}'。也就是说关闭强制 HTTPS 后,www 跳转保留原请求 scheme({scheme}),不会把 HTTP 流量强行抬升到 HTTPS。反向代理(reverse_proxy)、gzip 编码、noindex 头部、basic auth 等中间件照旧生成,行为不受影响。

Service 应用:用存储值替换硬编码的 true

原先 Service 应用在生成 Traefik/Caddy 标签时把 is_force_https_enabled 写死为 true。现在改为传入 ServiceApplication::isForceHttpsEnabled 的存储值,使域名页开关真正作用到标签上。在 bootstrap/helpers/docker.php 等聚合生成处,也能看到普通应用以 $application->isForceHttpsEnabled() 传入相同参数的对称写法。

Preview 部署继承父应用既有的跳转设置,与当前普通应用行为保持一致(预览应用拥有独立的 ApplicationSetting 记录、在创建时复制父应用配置,因此继承自然成立)。

校验、授权与保存流程

面向用户的迁移与使用流程

更新后的 Cloudflare all-resource 部署指南建议用户按以下三步配置:

  1. https:// 保存资源的公网域名(如实记录对外 URL);
  2. 在 Domains 页为该资源关闭 Redirect HTTP to HTTPS
  3. 让 Cloudflare 负责公网跳转与 TLS 终结。

指南同时保留完整 TLS 方案作为备选:需要 cloudflared 与 Coolify 的 HTTPS 入口之间走 TLS 的用户,可继续使用 origin TLS 工作流,而不必关闭此开关。

手工冒烟验证步骤(设计文档推荐):

  1. 将一个 Cloudflare Tunnel 主机名指向 http://localhost:80
  2. 在 Coolify 中把资源域名保存为 https://
  3. 关闭该资源的 Redirect HTTP to HTTPS
  4. 用浏览器访问公网 HTTPS URL,确认页面正常加载、不再出现重定向死循环。

测试矩阵:自动化覆盖点

设计与实现对应的自动化测试需覆盖:

场景 断言要点
普通应用 HTTPS 域名 + 开关开启/关闭 对应跳转中间件存在/不存在
Service 应用 HTTPS 域名 + 开关开启/关闭 同上,且通过 Service 侧 UI 持久化
Service 应用默认值 迁移/新建后保持启用
Traefik 与 Caddy 关闭时仅省略跳转行为,gzipstripprefix、auth、noindex、www/non-www 等中间件完整保留
HTTP-only 资源 UI 不显示无关开关(见 tests/Feature/ServiceDomainsTest.php
Domains UI 持久化 遵循既有授权规则(见 tests/Feature/ApplicationDomainsTest.php

兼容性与安全

  • 数据库列默认值为 true,保证 Service 应用存量行为不回归;普通应用既有值原样保留。
  • 不做自动迁移推断:不会猜测哪些资源位于 Cloudflare 之后,避免把默认行为静默改写为不安全的直连转发。

需要深入阅读源码时,可依次对照:设计文档 docs/superpowers/specs/2026-08-17-external-tls-http-redirect-design.md、标签生成核心 bootstrap/helpers/docker.php、模型定义 app/Models/ServiceApplication.phpapp/Models/ApplicationSetting.php、迁移文件 2026_08_17_000000_add_is_force_https_enabled_to_service_applications_table.php,以及上述 Feature/Unit 测试。

登录后查看全文
热门项目推荐
相关项目推荐