iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 23

Day 23 — 第一次上線:Cloudflare Pages + 自訂網域

  • 分享至 

  • xImage
  •  

今天要解的問題

CI 綠了(Day 22),平台選定了(Day 21)。今天真的上線。

流程看起來很簡單:連 repo、設定建置、綁網域。但有三件事值得寫:DNS 的 apex 問題部署後的驗證、以及一個我實際踩到的「本機過但線上壞」。

部署設定

Cloudflare Pages 有兩種接法:

方式 說明
Git 整合 連 GitHub repo,push 就部署。設定最少
Direct Upload(wrangler) 由我的 CI 決定何時部署。可以「CI 綠了才部署」

我選第二種,理由是 Day 22 的原則:部署必須在驗證通過之後。Git 整合會在 push 的同時開始建置,與 CI 平行跑——這意味著 CI 紅燈的東西可能已經上線了。

# .github/workflows/deploy.yml
name: deploy

on:
  workflow_run:
    workflows: [verify]          # ← 依附在 verify 之後
    types: [completed]
    branches: [main]

jobs:
  deploy:
    # 只有 verify 成功才部署
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.workflow_run.head_sha }}   # 部署被驗證過的那個 commit

      - name: 產生資產版本戳(Day 24 會改成內容哈希)
        run: node scripts/stamp-assets.js

      - name: 部署到 Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          accountId: ${{ secrets.CF_ACCOUNT_ID }}
          command: pages deploy . --project-name=learnpath --commit-dirty=true

ref: github.event.workflow_run.head_sha 這行很重要:workflow_run 觸發時,預設 checkout 的是 main 的最新狀態,而那可能已經有新的(未驗證的)commit。明確指定被驗證過的那個 SHA,才能保證「部署的就是通過測試的那份」。

API token 的權限

不要用 Global API Key。建一個最小權限的 token:

Permissions:
  Account → Cloudflare Pages → Edit
Account Resources:
  Include → 只選你的帳號
(不需要任何 Zone 權限)

存進 repo secrets(Settings → Secrets and variables → Actions)。這個 token 洩漏的最壞後果是「有人可以部署我的 Pages 專案」——不好,但比洩漏能改 DNS 的 key 好得多。

排除不該部署的檔案

# .cfignore(語法同 .gitignore)
scripts/
doc/
wiki/
AGENTS.md
.github/
*.md

scripts/ 是開發工具(Day 1 就分開的界線,今天收割);doc/wiki/ 是文件。它們進了 git 但不該進網站——部署範圍不等於版控範圍

順手驗一下:

npx wrangler pages deploy . --project-name=learnpath --dry-run 2>&1 | grep -c 'scripts/'   # 應為 0

DNS:apex 的老問題

我要 learnpath.example.com(子網域)當主站,www 轉址。子網域很簡單:

CNAME  learnpath  →  learnpath.pages.dev    (Proxied 橘雲)

如果要用 apexexample.com)就有 RFC 層面的問題:apex 不能有 CNAME(因為它必須有 SOA/NS 記錄,CNAME 與其他記錄互斥)。

三種解法:

方式 說明
CNAME flattening DNS 商在回應時自己解析 CNAME 目標並回傳 A 記錄。Cloudflare 原生支援
ALIAS/ANAME 部分 DNS 商的自訂記錄類型(Route 53 的 Alias、DNSimple 的 ALIAS)
寫死 A 記錄 ❌ 平台換 IP 你就掛了

Cloudflare 當 DNS 就直接用 flattening——在 Cloudflare 的 DNS 面板加 CNAME @ → learnpath.pages.dev,它會自動 flatten。

轉址規則(www → apex):

Rules → Redirect Rules → Create
  When: hostname equals "www.learnpath.example.com"
  Then: Dynamic redirect
    Expression: concat("https://learnpath.example.com", http.request.uri.path)
    Status: 301
    Preserve query string: ✓

用 301 不用 302。 301 是永久轉址,會被瀏覽器與搜尋引擎快取;302 每次都要多一趟。而 Preserve query string 別漏——不然 www.../chapter.html?ch=ch09 會轉到沒有參數的首頁。

HTTPS 與憑證

Cloudflare 自動簽發(Universal SSL),約 1–2 分鐘生效。有兩個設定要檢查:

SSL/TLS → Overview → 模式:Full (strict)
SSL/TLS → Edge Certificates
  ☑ Always Use HTTPS          (http:// 自動轉 https://)
  ☑ Automatic HTTPS Rewrites
  Minimum TLS Version: 1.2

Flexible 模式絕對不要用(那是 Cloudflare 到來源用 HTTP,等於半套加密)。Pages 作為來源本身就是 HTTPS,所以 Full (strict) 沒有任何額外設定成本。

HSTS 留到 Day 25 一起設——它是不可逆的(一旦瀏覽器記住了,你的網域在 max-age 期間只能用 HTTPS),要確認 HTTPS 完全穩定才開。

上線後驗證(很多人漏掉這步)

本機通過不等於線上通過。 至少四類問題只在線上出現:

  1. 檔名大小寫(本機不分、Linux 分)。
  2. 絕對/相對路徑在不同部署根目錄下的差異。
  3. 快取(舊資產被瀏覽器或 CDN 留住)。
  4. HTTP header(本機的 http.server 沒有 CSP、沒有 Cache-Control)。

所以我讓 smoke test 支援打線上網址:

# smoke-test.py 開頭改成可從環境變數覆寫
import os
BASE = os.environ.get("SMOKE_BASE", "http://localhost:8901")
      # deploy.yml 追加:部署後打線上
      - name: 等 Pages 生效
        run: |
          for i in $(seq 1 40); do
            code=$(curl -so /dev/null -w '%{http_code}' https://learnpath.example.com/index.html)
            [ "$code" = "200" ] && break
            sleep 3
          done
          [ "$code" = "200" ] || { echo "::error::線上首頁回應 $code"; exit 1; }

      - name: 線上 smoke test
        env:
          SMOKE_BASE: https://learnpath.example.com
        run: |
          pip install --quiet selenium
          python3 scripts/smoke-test.py

      - name: 線上 header 檢查
        run: bash scripts/check-headers.sh https://learnpath.example.com

check-headers.sh 今天先做基本版(Day 25 會擴充):

#!/bin/bash
# scripts/check-headers.sh <base-url>
set -u
BASE="${1:?用法: check-headers.sh https://example.com}"
fail=0

check() {   # check <path> <header-regex> <說明>
  local path="$1" pattern="$2" desc="$3"
  if curl -sI "$BASE$path" | grep -iqE "$pattern"; then
    echo "  ✓ $desc"
  else
    echo "  ✗ $desc"
    fail=1
  fi
}

echo "== $BASE =="
check "/index.html" '^HTTP/[0-9.]+ 200'          "首頁 200"
check "/index.html" 'content-type:.*text/html'    "HTML content-type"
check "/css/style.css" '^HTTP/[0-9.]+ 200'        "CSS 可取得"
check "/vendor/mathjax/tex-chtml.js" '^HTTP/[0-9.]+ 200' "自架 MathJax 可取得"
check "/js/data/search-index.js" '^HTTP/[0-9.]+ 200'     "搜尋索引可取得"

# HTTP 應轉 HTTPS
loc=$(curl -sI "http://${BASE#https://}/index.html" | grep -i '^location:' || true)
case "$loc" in *https://*) echo "  ✓ HTTP → HTTPS 轉址";; *) echo "  ✗ HTTP 未轉 HTTPS"; fail=1;; esac

# www 應 301 到 apex
loc=$(curl -sI "https://www.${BASE#https://}/chapter.html?ch=ch09" | grep -i '^location:' || true)
case "$loc" in *"${BASE#https://}/chapter.html?ch=ch09"*) echo "  ✓ www 301 保留 query";; 
               *) echo "  ✗ www 轉址異常:$loc"; fail=1;; esac

exit $fail

我實際踩到的「本機過、線上壞」

部署完第一次打開,課文頁的公式沒有渲染。本機完全正常。

Console 訊息:

Refused to load the script 'https://learnpath.example.com/vendor/mathjax/tex-chtml.js'
because it violates the following Content Security Policy directive: "script-src 'self'"

看起來很荒謬——那明明是同源的 'self'

真正的原因是兩層 CSP 疊加

  1. HTML 的 <meta> CSP(Day 16 收緊成 script-src 'self')。
  2. Cloudflare Pages 的 _headers 我也寫了一份 CSP(Day 21 為了 frame-ancestors)。

而我在 _headers 那份裡漏寫了 script-src,只寫了 default-src 'self'; frame-ancestors 'none'。看起來 default-src 'self' 應該涵蓋 script——確實會。但兩份 CSP 是各自獨立生效、取交集的:任何一份拒絕就是拒絕。

問題出在我 _headers 裡的 default-src 後面誤寫了 'none'(複製貼上 object-src 'none' 時手滑):

# 錯的
/*
  Content-Security-Policy: default-src 'none'; frame-ancestors 'none'

default-src 'none' = 什麼都不准載。而本機的 http.server 不會送任何 header,所以只有 meta 那份生效,一切正常。

三個教訓:

  1. 多個 CSP 來源會取交集,不是覆蓋。debug 時要看全部的 CSP 來源(DevTools > Network > 該請求 > Headers,加上 HTML 的 meta)。
  2. 本機伺服器不送 header,所以 header 相關的錯誤只在線上出現。 這正是「部署後要跑 smoke test」的價值——我的 smoke test 有「console 無 SEVERE」的斷言(Day 10),如果那時候有跑線上版,CI 就會直接擋住這次部署。
  3. Day 25 我會只保留一份 CSP(header 版),把 meta 那份移除,避免這種疊加困惑。

(修法先是把 _headers 的 CSP 改成與 meta 一致的完整版;Day 25 會做正式的整理。)

部署預覽:每個 PR 一個網址

Cloudflare Pages 對非 production 分支會給 preview URL。加進 workflow:

  preview:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: cloudflare/wrangler-action@v3
        id: cf
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          accountId: ${{ secrets.CF_ACCOUNT_ID }}
          command: pages deploy . --project-name=learnpath --branch=${{ github.head_ref }}
      - name: 在 PR 留言預覽網址
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner, repo: context.repo.repo,
              body: `🔍 預覽:${{ steps.cf.outputs.deployment-url }}`
            })

preview 環境要禁止搜尋引擎索引,否則會與正式站競爭:

# _headers
https://:project.pages.dev/*
  X-Robots-Tag: noindex

踩到的雷

--commit-dirty=true 是必要的。 因為 stamp-assets.js 會修改工作目錄(注入版本戳),wrangler 預設會拒絕部署有未 commit 變更的目錄。加這個旗標明確允許——這是刻意的:建置產物不進版控,只在部署時產生。

Pages 的 SPA fallback 預設是開的。 它會把找不到的路徑導向 /index.html(200 而不是 404)。對我這個多頁式網站是錯的:/nonexistent.html 應該回 404,不該回首頁內容(那對 SEO 是災難——所有錯誤網址都變成「首頁的副本」)。

修法:加一個 404.html,Pages 會優先用它:

<!-- 404.html -->
<h1>找不到這一頁</h1>
<p><a href="index.html">← 回學習地圖</a></p>

驗證:

curl -so /dev/null -w '%{http_code}\n' https://learnpath.example.com/nope.html   # 應為 404

憑證生效前的空窗期。 綁定網域後約 1–2 分鐘內,HTTPS 會回 525526。這期間 CI 的部署後驗證會失敗——所以那段輪詢等待(for i in $(seq 1 40))是必要的,而且要等 200 而不是「任何回應」。

驗證

bash scripts/check-headers.sh https://learnpath.example.com
SMOKE_BASE=https://learnpath.example.com python3 scripts/smoke-test.py

手動清單:

  • 首頁地圖 18 節點、hero 數字正確。
  • 課文頁公式有渲染(今天的 CSP 事故的回歸點)。
  • console 完全乾淨(無 SEVERE)。
  • http://https://www → apex(保留 query)。
  • /nope.html → 404(不是首頁)。
  • Service Worker 註冊成功、離線重整仍可讀(Day 16 的成果在真實 HTTPS 下才算完整驗證——SW 需要安全來源)。
  • 手機實機打開(不只是 DevTools 模擬):地圖橫捲、字型正常、深色模式跟隨系統。
  • Network 面板:零第三方請求

小結與明天預告

今天的重點:

  1. 部署依附在 CI 之後workflow_run + conclusion == 'success'),並 checkout 被驗證過的那個 SHA。
  2. apex 網域用 CNAME flatteningwww 用 301 且保留 query string。
  3. 部署範圍 ≠ 版控範圍.cfignore 排除 scripts/doc/wiki/
  4. 上線後一定要跑一次 smoke test 打線上網址。 本機的 http.server 不送 header,所有 header 相關的錯誤只在線上出現。
  5. 多份 CSP 取交集不是覆蓋——這是我今天的事故,也是 Day 25 要整理成單一來源的原因。
  6. Pages 的 SPA fallback 對多頁式網站是錯的,加 404.html 修掉。

明天處理一個從 Day 2 就存在、我一直手動維護的技術債:css/style.css?v=20260723d 這種手寫版本號。我會寫一支 60 行的腳本用內容哈希取代它,而且不引入 Vite


上一篇
Day 22 — CI 先行:GitHub Actions 跑雙層驗證
系列文
知識圖譜 : 技能樹式學習歷程23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言