EXPLAINER   custom domain routing / from caddy to cloudflare for saas
2026-05-10 · long form

/ Custom Domain
從第一原理講起。

這是一份從 「客戶網域為什麼要 routing」 開始講起的長文。 一路解釋 Caddy 為什麼簡單、CF 的設計取捨、為什麼 1000 條 routes 是極限、 為什麼 hostname 需要綁定 zone、為什麼 worker 不能直接當 origin。

讀完之後你會懂:今天 ship 的這些 cf-saas-origin.vibehost.com / fallback_origin / per-host route 不是隨便寫的、每個都對應 CF 設計 一個約束。也會懂為什麼 10w 規模再來會撞牆、撞牆原因是什麼、要怎麼準備。

TL;DR

  • Caddy 沒這問題是因為它是 single-tenant binary、沒有「這個 hostname 屬於誰」的問題——所有 :443 都是它的。
  • CF 邊緣是 multi-tenant anycast——同一個 IP 服務全網路所有客戶、必須查 internal index 才知道 hostname 屬於哪個 zone、哪個 SaaS provider。
  • CF for SaaS 解的是這個 multi-tenant 索引問題——不是 cert 自動化(那是順帶的)。
  • 1000 routes 限制是 edge config 全球 propagation 的物理約束、不是商業限制。
  • fallback_origin 不能直接是 worker是因為 CF for SaaS 在 routing layer 之前運作、worker route layer 在它之後、設計上這兩條 pipeline 是序列。
· · · · ·

01 問題本質:custom domain routing 要解什麼

把所有變數丟一邊、純粹從問題定義開始。我們有一個 SaaS、客戶想用 他們自己的網域名稱 訪問他們的內容。具體場景:

客戶的瀏覽器
       │
       │  GET https://blog.example.com/posts/hello
       │  (客戶買了 example.com、希望放在我們的 SaaS 上)
       │
       ▼
  ??? 我們要做什麼 ???
       │
       ▼
  回客戶上傳的 HTML 或 SSR Worker 結果

從「??? 」要做什麼角度看、需要解決 三件事

1.1 DNS 把客戶的 hostname 解到我們的 IP

這個簡單、不是我們做的——客戶在他自己 DNS provider(GoDaddy / Cloudflare / route53)設一條 record 指向我們。我們頂多告訴他「請設 blog.example.com CNAME → cname.vibehost.host」。

1.2 TLS 終止 — 替客戶的 hostname 拿到 cert

這是真的工程問題。Browser 連 https://blog.example.com、TLS handshake 時看 server cert 的 CN/SAN 必須是 blog.example.com。我們的 server 不能用自己 vibehost.com 的 cert——browser 會抱怨 host mismatch。

所以我們需要 動態替每個 customer hostname 拿到對應的 cert。Cert 來源通常是 Let's Encrypt(免費)、需要 ACME 協議完成 domain ownership challenge(HTTP-01 或 DNS-01)。

1.3 Routing — 知道這個 hostname 屬於哪個 customer

同一個 server 上、blog.example.com、acme.com、myapp.io 都連進來。我們收到一個 TLS-already-terminated 的 request、看 Host: header、要查到「這個 host 對應到哪個 customer 的哪個 deployment」、然後 forward 給對的 worker / R2 bucket / 等等。

這三件事看起來不複雜——但每一件都有規模問題、安全問題、跟誰能 issue 那個 cert 的歸屬問題。

· · · · ·

02 Caddy 怎麼解的(為什麼簡單)

Caddy 是個跑在 單一 VM 上 的 reverse proxy binary。它有個叫 on-demand TLS 的功能、解釋我們的問題很乾淨:

Caddy on-demand TLS 流程

  1. browser 連 https://blog.example.com
  2. TLS handshake、SNI 帶 blog.example.com
  3. Caddy 看「我有沒有這個 hostname 的 cert?」
       └─ 沒有 → 觸發 on-demand 流程
            ├─ 先 ask 上游 API:「這個 hostname 我能 issue 嗎?」
            │     └─ API 查 DB、確認該客戶有付費 + DNS 真的指我們
            ├─ 跑 ACME 跟 Let's Encrypt 對話
            │     └─ HTTP-01: LE 打 :80 拿 challenge file
            │        └─ Caddy 自己接 :80、回 token
            ├─ 拿到 cert、cache 到 disk
            └─ 完成 TLS handshake、proxy request 到 origin

2.1 為什麼這樣 work?

三個 enabler:

條件Caddy 怎麼滿足
同一個 IP 接所有 hostname客戶 DNS 全部 CNAME 到 cname.vibehost.host 的兩個 GCE IP。Caddy 就在那兩個 IP 上、所有 port 443 都是它的、無爭議。
SNI 直接讀 HostTLS handshake 第一個 ClientHello 就帶 SNI。Caddy 從 SNI 拿 hostname、用作 cert lookup key。沒有「這 hostname 屬於哪個 zone」問題——hostname 屬於 Caddy。
ACME HTTP-01 workLE 要打 http://blog.example.com/.well-known/acme-challenge/<token>。客戶 DNS 已經指 Caddy 的 IP、:80 也是 Caddy 接、Caddy 知道 token、回給 LE。1 跳完成。

2.2 為什麼 quota / route limit 不是問題

Caddy 跑在 我們自己的 VM 上、disk / memory / CPU 都是我們的、cert 存我們 disk。沒人會說「你只能 issue 1000 個 hostname」——我們 disk 有多大就放多大。每個 hostname 是 dict 裡的 key、無上限。

Routing index 也是 in-memory hashmap、O(1) lookup、10 萬個 hostname 也就 ~10MB 的 keys。

關鍵 insight

Caddy 是 single-tenant 邏輯——這台 VM 的所有 :443 traffic 都是「我們」的、沒有「另一個 SaaS provider 也在這 IP 上」的問題。所有 hostname、cert、routing index 都在 Caddy 自己 process 內、O(1) 查、無 quota。

· · · · ·

03 為什麼 Cloudflare 結構上不一樣

CF 不是 single VM。CF 是 全球 anycast 網路——300+ 個 PoP、每個 PoP 都響應 CF 的 anycast IP block(104.21.x.x / 172.67.x.x 等)。同一個 IP 同時是全網所有 CF 客戶的 IP

CF anycast 模型

  Browser 連 104.21.52.16
       │
       │  BGP announces — 全球 300+ PoP 都 ad 這個 IP
       │  最近的 PoP 接住(geo + load + RTT)
       │
       ▼
   假設 PoP-SIN 接住
       │
       │  TLS handshake: SNI 帶 hostname
       │  比方說 SNI=blog.example.com
       │
       ▼
   PoP 內部要查:
       「這個 hostname 屬於哪個 CF 客戶?」
       └─ 因為同一個 IP 上、 paypal.com 也在
       └─ netflix.com 也在、acme.com 也在...
       └─ 每個 CF 客戶都用這 anycast IP
       └─ PoP 必須有 index:hostname → tenant config

3.1 CF 的 internal routing index — zone-based

CF 把客戶分為 zone(一個 zone 通常對應一個 domain:vibehost.com 是一個 zone、example.com 是另一個 zone)。每個 zone 有自己的設定:firewall rules、cache rules、worker routes、SSL settings、page rules、等等。

當 PoP 收到 SNI blog.example.com、它做:

  1. 從 hostname 抽 apex domain → example.com
  2. 查 CF 全球 zones index → example.com 是 zone X
  3. 載 zone X 的所有設定(rules / routes / SSL config)
  4. 對這個 request 應用 zone X 的 pipeline
  5. 沒有 zone X 對應 → 預設行為(拒絕、或上游 fallback)

注意:「找到 zone」是任何 CF 行為的前提。zone 沒被 CF 認得、就什麼都不會發生——CF 邊緣完全不知道要怎麼處理這條 request、最多回 530 generic error。

3.2 為什麼這帶來 scale 問題

Caddy 一台 VM、一個 in-memory dict、10 萬 entries 沒事。CF 邊緣是 300 個 PoP、每個 PoP 都要持有完整 routing index——所以 entries 多大、要 sync 多廣、傳播多慢、都有物理限制。

CF 內部 routing config 有兩層:

  • Hot path(in-memory、每個 PoP 全有、~ms 級 lookup):zone metadata、SSL config、active worker routes
  • Slower(lazy-loaded from CF central、KV-style):個別 page rules、custom hostnames 列表(per-zone)、analytics config

每個 PoP 都要全有 hot path 的東西、所以這部分 必然有 quota——不是商業策略、是物理約束。

為什麼 quota 存在

1000 worker routes / zone 不是 CF 想壓榨你 ── 是 每個 PoP 都要快速 hash 比對「這個 request URL 要 trigger 哪個 worker」。Hash table 跨 300 個 PoP 都要快速更新——表越大、傳播越慢、cache invalidation cost 越高。

· · · · ·

04 CF for SaaS 到底是什麼產品

名字有點 misleading——它不是「給 SaaS 用的 CF」、就只是 CF。CF for SaaS 解的具體問題是:

當客戶的 hostname (e.g. blog.example.com) 不在我的 zone (e.g. vibehost.com) 時、怎麼讓 CF 邊緣認得它?

因為 CF routing 是 zone-based、預設 blog.example.com 邊緣會被解析為「這 hostname 屬於 example.com zone」——這個 zone 不在我的 CF account 下、CF 邊緣不會把流量送到我這裡。

4.1 CF for SaaS 的核心抽象:Custom Hostname

它讓我(SaaS provider)聲明領養一個不屬於我 zone 的 hostname:

POST /zones/{vibehost.com_zone_id}/custom_hostnames
{
  "hostname": "blog.example.com",
  "ssl": { "method": "http", "type": "dv" }
}

這個 API 對 CF 說的是:

  • blog.example.com 是 vibehost.com zone 的客戶。」
  • 「請替它自動 issue cert(用 HTTP-01 DCV)。」
  • 「如果 anycast 收到 SNI 是這 hostname 的 request、把它當 vibehost.com zone 的 traffic 處理。」

但客戶的 DNS 還是要先指 CF anycast——通常透過 customer 設 blog.example.com CNAME → cname.vibehost.hostcname.vibehost.host 是個 proxied record(指 CF anycast)。

4.2 為什麼一定要在 SaaS provider 自己的 zone 註冊

因為 zone 是 CF routing 的 unit。CF 邊緣收到 request、做的第一件事是「這 hostname 屬於哪個 zone?」。CF for SaaS 把答案改成「不是 example.com zone(客戶的)、而是 vibehost.com zone(SaaS provider 的)」、然後套用 SaaS provider zone 的設定(包含 worker routes、fallback origin、etc)。

這也是 issue #333 那個 zone 環境變數錯誤撞到的事——我們把 hostname 註冊在 .space zone(tenant FQDN zone)、但 CNAME target cname.vibehost.host.com zone。CF 邊緣收到 lacoste.projectd.cc → 查 example.com 上游 → 看到 CNAME 指 vibehost.com → 以 .com zone 處理 → 找不到 lacoste.projectd.cc 在 .com zone 的 custom_hostname 註冊、結果 522。

心智模型

CF for SaaS 是個「替別人 zone 的 hostname、在我自己 zone 上開後門」的機制。這個後門的設定(fallback origin、worker route)必須跟 hostname 註冊的 zone 一致——否則 CF 邊緣不知道要套用哪個 zone 的 pipeline。

· · · · ·

05 為什麼 hostname 要綁 zone — routing index 是 per-zone

具體 trace 一個 request、從 SNI 到實際 worker 觸發:

browser https://blog.example.com
   │
   ├─ 1. DNS resolve blog.example.com
   │     └─ CNAME → cname.vibehost.host
   │     └─ A → 104.21.52.16 (CF anycast)
   │
   ├─ 2. TLS handshake to 104.21.52.16
   │     └─ SNI: blog.example.com
   │
   ├─ 3. CF PoP 收到 → "這個 SNI 屬於誰?"
   │     ├─ 查 zones index for "blog.example.com" → none
   │     ├─ 查 zones index for "example.com" → 不在我們 CF account
   │     └─ 查 custom_hostnames index → 找到 blog.example.com 註冊在 vibehost.com zone
   │
   ├─ 4. 確定屬於 vibehost.com zone、套用該 zone pipeline:
   │     ├─ 載 vibehost.com zone 的 worker routes
   │     ├─ 載 vibehost.com zone 的 firewall / WAF / cache rules
   │     ├─ 對這個 request URL 比對 routes
   │     │   └─ pattern blog.example.com/* 比對 → 中
   │     │   └─ 觸發 vibehost-dispatch worker
   │     └─ 沒中 → 走 fallback_origin (HTTP fetch 出去)
   │
   └─ 5. worker 收到 request、處理、回給 browser

5.1 為什麼這 design 必然要 hostname 跟 zone 一致

第 4 步是關鍵——CF 邊緣 只用一個 zone 的設定(找到 zone X、就套用 zone X 的所有 routes / rules)。如果你 worker route 設在 zone Y、但 hostname 註冊在 zone X、那 zone Y 的 routes 不會被應用。

這是為什麼 P6.5 第一輪我設錯——hostname 註冊在 .space zone、worker route 也設在 .space zone。乍看一致——但 customer 的 CNAME target 是 cname.vibehost.host.com zone)、這條 CNAME chain 把 traffic 跟 .com zone 綁在一起、CF 找 hostname 時是查 CNAME chain 終點所在的 zone

5.2 結論

東西必須在哪個 zone原因
CNAME target host (cname.vibehost.host)SaaS provider zone (vibehost.com)客戶 CNAME 指這、CF 邊緣 trace 到這個 zone
Custom Hostname registration同 SaaS provider zone邊緣才能在這 zone 認領
Fallback origin record同 SaaS provider zone同 zone 邊緣 routing pipeline 才認
Worker route同 SaaS provider zoneroute 比對是 per-zone、跨 zone 不會 trigger
· · · · ·

06 為什麼 worker route 有 1000 條上限

這是 P6.5 第二輪卡住的真實原因。CF API token 拒絕 */* wildcard、我只能加 per-host 精確 pattern:

POST /zones/{vibehost.com}/workers/routes
{ "pattern": "lacoste.projectd.cc/*", "script": "vibehost-dispatch" }   → ok
{ "pattern": "cpbl.ladykaren.org/*",  "script": "vibehost-dispatch" }   → ok
...
{ "pattern": "*/*",                   "script": "vibehost-dispatch" }   → 9106 rejected

6.1 為什麼有 1000 條上限

回到第 03 節講的——CF 邊緣 每個 PoP 都要全 cache hot routing index。每條 worker route 是 hot path 的一個 entry:

  PoP 收到 request URL https://blog.example.com/posts/123
  ↓
  比對 zone (例如 vibehost.com) 上所有 worker routes:
    ├─ blog.example.com/*     hash match? → yes → trigger
    ├─ acme.com/*             hash match? → no
    ├─ cf-saas-origin.vibehost.com/*  hash match? → no
    └─ ... (依此類推、每條都要比)

1000 條已經很多——每個 request 都要過完比對。對 CF 而言、想保證 PoP 內 routing decision ≤1ms、就得限制 hot index 大小。

6.2 為什麼 */* wildcard 是逃出口

一條 wildcard pattern */* 取代 N 條 per-host pattern——hot index 只有 1 個 entry。比對時 hash 比對快、O(1)、無上限問題。

但 wildcard 有個語意 trap:它會 match zone 上所有 traffic。如果 zone 是 vibehost.com、wildcard 會 catch vibehost.com apex / api.vibehost.com / www.vibehost.com——所有 host。Worker 要自己邏輯處理「這 host 是 platform-internal 還是 customer hostname」、不能再依賴 route pattern 過濾。

我們 P6 round 4 加的 dispatcher worker PLATFORM_HOSTS bypass 邏輯就是處理這個——當看到 host 是 platform-internal 就 fall through fetch 過去:

// dispatcher worker
const platformHosts = (env.PLATFORM_HOSTS || "").split(",");
const isPlatformHost = platformHosts.some(h => hostname === h || hostname.endsWith("." + h));
if (isPlatformHost) {
  return fetch(request);  // passthrough — let zone-default routing handle
}
// otherwise it's a customer hostname → KV lookup → dispatch

6.3 為什麼 API token 拒絕 */*

CF wildcard pattern 影響面太大、需要 account-level admin 權限。Scoped API token(即使有 Workers Edit)也無法加 */*

解法:在 dashboard 手動加(一次性、5 分鐘)、或 rotate 到 legacy global API key(不 recommend、安全性差)。

為什麼 1000 條 + per-host 不能 scale

10w hostname × 1 條 per-host route = 10w 條、爆 1000 上限 100 倍。必須走 wildcard 路。今天 ship 是 per-host hack(5 個客戶內 OK)、未來規模化前必修。

· · · · ·

07 為什麼是 fallback_origin、不是 worker 直接當 origin

這是 P6.5 第二輪 522 卡住的關鍵 misconception。直觀想:worker 直接當 origin 不就好了?實際上、CF 設計上 不行

7.1 CF for SaaS pipeline 跟 Worker pipeline 是序列、不是並列

CF 邊緣 pipeline (簡化版)

  Layer 1: TLS terminate (SNI lookup)
        ▼
  Layer 2: CF for SaaS resolution
        └─ 「這個 hostname 屬於哪個 zone?」
        └─ 透過 custom_hostnames 註冊找到 SaaS zone
        ▼
  Layer 3: zone-level pipeline (firewall / WAF / cache rules / ...)
        ▼
  Layer 4: Worker routes
        └─ 比對 request URL pattern
        ▼
  Layer 5: 沒中 worker route → go to origin
        └─ 從 zone 設定查「origin 是什麼?」
        └─ 如果 zone 是 SaaS zone、查 fallback_origin
        ▼
  origin server (HTTP fetch)
        └─ HTTP response 回來、流回 layer 4 → 3 → 1 → browser

7.2 fallback_origin 的角色

fallback_origin 是 layer 5 的東西——CF 邊緣處理完所有 layers 1-4 之後、需要從某個 origin 拿 response 才能回給 browser。fallback_origin 告訴 CF:「對這 zone 的 SaaS traffic、最後 fallback 去 fetch 這個 origin host」。

那 worker route(layer 4)行為是怎樣?worker route 在 layer 5 之 觸發——如果 worker route 比對中、worker 就接管整個 response、不會 hit fallback origin。

7.3 為什麼 cf-saas-origin.vibehost.com 這 host 是 originless

我們的 setup:

fallback_origin (.com zone)  =  cf-saas-origin.vibehost.com
DNS record cf-saas-origin    =  AAAA 100:: proxied
worker route (.com zone)     =  *(per-host, e.g. lacoste.projectd.cc/*)
                                 → vibehost-dispatch script

具體 trace 客戶 hostname 流量:

  1. browser → CF anycast (TLS, SNI=lacoste.projectd.cc)
  2. layer 2 CF for SaaS: 找到 lacoste.projectd.cc 註冊在 .com zone
  3. layer 3-4: 套用 .com zone pipeline
  4. worker route 比對: pattern lacoste.projectd.cc/* 中 → trigger dispatcher
  5. dispatcher 接管、查 KV、forward 到 customer worker、回 response
  6. fallback_origin 從未 fetch(因為 layer 4 就接管了)

fallback_origin 在 happy path 不會被打到——但 CF 邊緣依然要求它存在、得 active 才允許 SaaS hostname 流量進入這 zone。它是 fail-safe、不是實際 origin。所以 100:: 這個假地址 OK——只要 record 在、CF 邊緣 routing 就會考慮這 zone。

7.4 為什麼不能直接「fallback_origin = worker URL」

這是 P6.5 round 1 我嘗試的——直接 bind cname-fallback.vibehost.space 到 dispatcher worker(透過 PUT /accounts/.../workers/domains)。CF 拒絕——因為 fallback_origin 是 HTTP fetch 對象、不是 worker route 對象。CF 邊緣要從那 host 拿 HTTP response、不是 invoke 一個 worker。

結果:fallback_origin 設成 worker custom domain → CF 邊緣 fetch HTTP → worker custom domain bind 機制不接 SaaS internal proxy 的 fetch(only direct external request fires)→ 整個 hop 走不通 → 522。

P6.5 round 1 教訓

CF for SaaS 不允許 worker 直接當 origin、因為 layer 4 (worker route) 跟 layer 5 (fallback_origin) 在邊緣 pipeline 裡是序列、不是並列。要讓 worker 處理 SaaS traffic、必須讓 worker route 在 layer 4 fire(透過 zone 上的 worker route 設定)、不是讓 worker 出現在 layer 5。

· · · · ·

08 */* wildcard 那個坑

解開了 layer 4 / layer 5 的設計、剩下的問題是 worker route pattern 怎麼寫才會在 SaaS internal forward 時 fire

8.1 Worker route pattern 比對的是 outer request URL

關鍵 nuance——CF 邊緣處理 SaaS traffic 時、不會改寫 incoming request URL。即使內部 routing 把 traffic 帶到 SaaS zone、worker route 比對的還是 https://lacoste.projectd.cc/...(原始客戶 hostname)。

  customer hostname:    lacoste.projectd.cc
  CNAME target:         cname.vibehost.host (in .com zone)
  fallback_origin:      cf-saas-origin.vibehost.com (also .com)

  CF 邊緣 pipeline 看到 request URL = https://lacoste.projectd.cc/path
  worker route 比對的是 lacoste.projectd.cc/...
  └─ 不是 https://cf-saas-origin.vibehost.com/path
  └─ 不是 https://cname.vibehost.host/path

所以 pattern cf-saas-origin.vibehost.com/* 不會 match lacoste.projectd.cc 的 SaaS traffic——這個 pattern 只 match 「客戶或我自己直接 hit cf-saas-origin.vibehost.com 的請求」、跟 SaaS forwarding 無關。

8.2 為什麼 CF 文件推薦 */*

因為 zone 上 SaaS traffic 的 outer hostname 是「任何客戶 hostname」——預先列舉不完。*/* 是「比對任何 zone 內 traffic」的唯一方式。

*/* 連 platform-internal traffic 都接、所以 dispatcher worker 必須有 PLATFORM_HOSTS bypass 邏輯(節 06.2)。

8.3 為什麼今天 ship 用 per-host 精確 pattern

API token 不允許加 */*(節 06.3)。為了 unblock cutover、我們手動加 per-host 精確 pattern 給 lacoste / cpbl / cf-saas-test 三個。三個還可以——10 萬不行。

正式解法兩條:

  1. 到 CF dashboard 手動加 */* route on vibehost.com zone(一次性、5 分鐘)。
  2. 長期:ship 一個 follow-up PR、自動化 verify 時 add per-host route(API token 加 per-host OK),然後在 1000 條接近時警示、轉到 wildcard。

第一條更乾淨——一條 */* route + dispatcher PLATFORM_HOSTS bypass 解所有未來 hostname。

· · · · ·

09 我們最後 ship 了什麼

最終 production 架構

   customer browser
        │
        │ DNS: blog.example.com → cname.vibehost.host
        │      cname.vibehost.host → CF anycast (proxied CNAME)
        ▼
   CF edge (anycast PoP)
        │
        ├─ TLS terminate
        │   └─ cert auto-issued by Google CA via CF for SaaS
        │
        ├─ CF for SaaS resolution
        │   └─ blog.example.com 註冊在 vibehost.com zone
        │
        ├─ Worker route on vibehost.com zone
        │   └─ pattern (per-host, e.g. blog.example.com/*) → vibehost-dispatch
        │
        ▼
   vibehost-dispatch worker
        │
        ├─ KV.get(custom-domain:blog.example.com) → { subdomain: "..." }
        ├─ KV.get(subdomain) → routeData (R2 prefix, visibility, password)
        ├─ /authz/check → AND-compose visibility / password / share-link gates
        │
        ▼
   R2 bucket serves static assets (or SSR worker for Next.js)
        │
        ▼
   response → CF edge → browser

對應今天 commit / merge 的東西:

Phase動了什麼Ship 形式
P1dispatcher worker 加 custom-domain branch + ownership-HTTP self-answerPR #306
P2CF Custom Hostnames API service + DB cf_* 欄位同上
P3API verify/delete fail-open sync 到 CF + KV同上
P4/authz/check 認 custom hostname、塞 cleanup_failed orphan deny path同上
P5backfill script (--dry-run / --confirm / --max-rows)同上
P6prod 手動 ops: migration / KV ID env / backfill / fallback origin / worker routes / DNS flipmanual
P7Caddy GCE VM stop(保留 disk)manual
— extra —install.sh BSD grep 修正PR #331

9.1 哪些是 hack、哪些是長期的

  • 長期:dispatcher worker 的 custom-domain routing、KV mapping、ownership self-answer、API service wrapping、backfill 流程——這些都會留下。
  • Hack:per-host worker route(接近 1000 上限就要換 */*)、cf_id 寫到 .space zone 的 bug(issue #333、要修)。
· · · · ·

10 10 萬 hostname 規模的 trade-offs

面向現況10w 撞牆?解法
CF for SaaS hostname quota Free 100、之後 $0.10 / hostname / month $10k/mo 付錢即可 — Dcard 可承受
Worker routes per zone per-host 精確 pattern × N 硬上限 1000 改用 1 條 */* route + dispatcher 內部 PLATFORM_HOSTS bypass
KV reads per request 2 reads/req (custom-domain + subdomain mapping) ~$300/mo 付費 OK;可選加 in-worker memory cache 降 p95
/authz/check load on API 每個 non-public req 一個 DB query 600 req/s peak API in-process cache + DB connection pool 即可
Cert renewals storm CF auto-renew、無 webhook 監控 每天 1700 個 renewal 必做 webhook receiver + dashboard alert (P9 follow-up)
Apex domain instructions 目前回 A → Caddy IP(壞的) ~30-40% 客戶會撞 改成 ALIAS / CNAME flattening guidance、或 CF anycast IP
DB scale partial unique index 已建 無壓 Postgres 對 10w rows 完全無痛

10.1 真正的 scale 順序

按 priority:

  1. */* wildcard route + dispatcher PLATFORM_HOSTS bypass — 沒這條、第 1001 個客戶就死。
  2. issue #333 (zone env split) — 沒這條、新客戶都需要 manual zone migration。
  3. cert renewal webhook receiver — 沒這條、10w 規模下每天有客戶 cert 出狀況、變紅鎖、不知道。
  4. apex domain instructions 修正 — 30-40% 客戶 onboarding 會 stuck。

1 + 2 是 hours 工作、3 + 4 是 days 工作。1 + 2 + 3 + 4 全做之後、架構就 scale 到 10w 沒問題。

10.2 結語

今天 ship 的 cf-saas-decommission 是 架構正確、規模需 4 個 follow-up。Caddy 的單 VM 模型在 5–10 個客戶內最簡單、但對 multi-tenant edge anycast 沒有路;CF for SaaS 的設計把 Caddy 那層複雜度從 「我們 own 一台 server」轉成 「我們 own 邊緣 routing 的 zone-level entries」——entries 跨全球 PoP 同步、所以有 quota;但同時也免費獲得 anycast、WAF、bot management、analytics、L7 DDoS 保護——這些 Caddy 沒有。