nginx origin 透傳設定

適用場景:業務收到 framework [swift-web] 控制台診斷「SSR 渲染的 canonical origin 與瀏覽器實際存取的 origin 不一致」,本頁給出排查路徑與 nginx / cloudflare 修復範例。

這條診斷是什麼

useSeo() 的 origin 決策是「單一事實源 + 顯式逃生口 + prerender 例外」三分支(按序):

L1. 顯式 useSeo({ siteUrl })          ← 單頁覆寫(跳過本診斷)
L2. NUXT_PUBLIC_SITE_URL              ← 設了它即**收斂**到該網域(opt-in,跳過本診斷)
L3. useRequestURL().origin(**預設**) ← 跟隨使用者實際存取的網域
      ├─ SSR Node 端:從 Host / X-Forwarded-Host / X-Forwarded-Proto 解析
      └─ CSR hydration 後:等價 window.location.origin

L2 是 opt-in 的收斂開關,不是預設行為。 未設 NUXT_PUBLIC_SITE_URL 時走 L3 跟隨存取網域 —— 這是多網域 / 防封網域站需要的預設(各鏡像各自被獨立收錄)。設了它才收斂到主網域。

⚠️ prerender 頁是唯一必須設定的情形:建置期沒有真實請求、拿不到 Host,origin 只能來自設定 (缺它 framework 直接建置報錯)。多網域站可把這些頁改用 isr: 3600 來避免被迫設定。

⚠️ 本診斷只在 L3 檔生效 —— 走 L1 / L2 時 canonical 與存取網域不同是預期行為,故跳過。

canonical / og:url / og:image 絕對路徑都用這個 origin 拼接。如果反代沒有把使用者存取的真實 Host 透傳到 Node,SSR 期 useRequestURL() 會退化到 Host 標頭預設值(通常是上游內網位址,如 127.0.0.1:3000),SEO 靜默污染。

為了讓運維不再「幾個月後才發現」,framework 在 CSR hydration 階段做一次比對:

document.querySelector('link[rel="canonical"]').href.origin   ← SSR 寫入
                            vs
window.location.origin                                          ← 瀏覽器實際存取

不一致即立刻在 console 輸出:

  • dev 環境:console.warn
  • 生產環境:console.error

每會話僅觸發 1 次(模組級 hasChecked 去重)。業務功能不受影響,但 SEO / 社交分享卡片會用錯誤網域。

必須透傳的 3 個標頭

Node 端 useRequestURL() 解析順序:

  1. X-Forwarded-Host 優先 → 沒有則用 Host
  2. X-Forwarded-Proto 優先 → 沒有則按目前監聽埠推斷(80=http / 443=https)

所以反代必須保證以下 3 個標頭都到達 Node 上游:

標頭作用必須否
Host主網域必須
X-Forwarded-Host透傳原始 Host(被多層反代時)推薦
X-Forwarded-Proto透傳 https 協定必須(啟用 HTTPS 時)

nginx 設定範例

最簡形態,純 nginx 反代到 pm2/Node:

server {
  listen 443 ssl http2;
  server_name www.example.com api.example.com;  # 多副網域都走這套 location
  ssl_certificate ...;
  ssl_certificate_key ...;

  location / {
    proxy_pass http://127.0.0.1:3000;

    # ─── 關鍵 3 行:透傳原始存取網域 + 協定 ─────────────────────
    proxy_set_header Host              $host;              # 使用者存取的網域(用 $host 而不是 $proxy_host)
    proxy_set_header X-Forwarded-Host  $host;              # 顯式備份(多層反代時)
    proxy_set_header X-Forwarded-Proto $scheme;            # https or http
    # ──────────────────────────────────────────────────────────

    # 其它常規透傳
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;

    # WebSocket / HTTP 1.1 升級(Nitro / Vite HMR 需要)
    proxy_http_version 1.1;
    proxy_set_header Upgrade           $http_upgrade;
    proxy_set_header Connection        "upgrade";
  }
}

常見錯誤:

# ❌ 用 $proxy_host(這是反代目標位址,等於直接寫 127.0.0.1:3000)
proxy_set_header Host $proxy_host;

# ❌ 寫死網域(多副網域 / 副網域收不到正確值)
proxy_set_header Host www.example.com;

# ❌ 漏 X-Forwarded-Proto,導致 https 站點的 canonical 變成 http://...
# (SEO 影響:http vs https 被搜尋引擎當作不同站點,權重稀釋)

cloudflare 設定注意

如果在 nginx 前面還有 cloudflare:

  1. Host header 行為:cloudflare 預設會把客戶端原始 Host 改成你的 origin 網域(在 Network → Origin Server Properties → "Host Header" 決定)。建議設為 "Preserve original Host header",讓 nginx 拿到的也是使用者輸入的網域
  2. SSL/TLS mode:必須 "Full (strict)",否則 cloudflare 可能不發送 X-Forwarded-Proto
  3. 多副網域 + Cloudflare:每個副網域都要單獨新增到 cloudflare,並 enabled proxy(橙雲)

排查命令

1. 用 curl 模擬特定 Host 存取

# 模擬使用者存取 www.example.com(而不是直連 IP)
curl -H "Host: www.example.com" http://your-vps-ip/some-page | grep canonical

# 期望:<link rel="canonical" href="http://www.example.com/some-page" />
# 實際拿到 http://127.0.0.1:3000/... = 反代沒透傳 Host

2. 在 Node 端列印 useRequestURL

臨時在 nuxt.config.ts 或某個測試頁加:

// app/pages/tests/_origin-debug.vue
<script setup>
const url = useRequestURL()
console.log('[SSR origin]', url.origin, 'hostname:', url.hostname, 'proto:', url.protocol)
</script>

部署到生產後存取一次,看 Node 日誌(pm2 logs)輸出的 origin 是不是預期值。

3. 瀏覽器端確認

// 在使用者實際存取的頁面 console 跑:
const c = document.querySelector('link[rel="canonical"]').href
console.log('canonical:', new URL(c).origin)
console.log('browser :', window.location.origin)

兩者應當一致;不一致 = nginx 透傳設定錯。

業務側繞過(營運緊急修復用)

運維修復需要時間但 SEO 急用?可以在呼叫 useSeo() 時顯式傳 siteUrl 暫時鎖定 origin:

useSeo({
  title: '...',
  description: '...',
  siteUrl: 'https://www.example.com',   // 顯式覆寫
})

傳 input.siteUrl 時 framework 會跳過本頁面的 origin 一致性診斷(視為業務主動鎖定)。運維修好 nginx 後建議移除這個臨時設定,回歸「單一事實源 + 自動診斷」模式。

NUXT_PUBLIC_SITE_URL 設定 {#site-url-config}

NUXT_PUBLIC_SITE_URL 環境變數有兩個用途:① 配了它,sitemap.xml 輸出的 siteUrl 就收斂到它(未配時 nuxt-site-config 按請求 origin 推斷,不是「鎖定」——多網域站必須配,解析順序見下文「與 useSeo 同一套解析」);② prerender / SSG 頁面的 useSeo origin 也取它(預渲染期沒有真實請求,useRequestURL() 是 localhost),該情境缺失直接建置 throw。framework 另在 module setup 階段做 host 格式校驗,失敗時 dev 期 console.warn / 生產 build console.error(格式校驗本身不阻斷 build;阻斷的是 prerender 頁缺 env)。

此值的歸屬:

場景誰設定設定什麼
本機開發開發自己http://localhost:3000 或測試環境位址(如 https://test.example.com)
測試環境部署運維測試網域(如 https://test.example.com)
生產環境部署運營 / 運維公司分配的官方主網域(如 https://www.example.com)

為什麼是運營 / 運維提供而不是開發自填:

  • 主網域是公司業務資產(備案、SSL 憑證、CDN 設定都跟它綁定)
  • 副網域 / 測試網域也由運營 / 運維決定(開發不應假設)
  • 開發本機用 localhost 佔位即可,不需要「知道」生產網域

校驗規則(host 格式嚴格校驗):

形態校驗結果
undefined / ''❌ 未設定
example.com(漏協定)❌ 不是合法 URL
<your-domain>(佔位符沒替換)❌ 不是合法 URL
ftp://example.com❌ 協定必須 http/https
https://example.com/api(帶 path)❌ 不應包含 path
https://example.com?lang=zh(帶 query)❌ 不應包含 query/hash
https://example.com✅
https://example.com/(末尾斜線)✅
http://localhost:3000(開發)✅

與 useSeo 的 origin 決策同構(舊文件寫「sitemap 始終用 NUXT_PUBLIC_SITE_URL」是錯的,已訂正):sitemap 的網域來自 nuxt-site-config 的優先級堆疊 —— nitro(-4) 壓請求推斷的 origin、runtime(0) 壓 env,數值大者勝 ⇒ 設了 env 用 env、留空則跟隨請求網域,與 canonical / og:url 完全一致。

⇒ 多網域 / 防封網域站應留空該變數:各鏡像存取 /sitemap.xml 各自拿到自己網域的 URL 列表,獨立被收錄(實測兩個不同 Host 請求,<loc> 各自跟隨)。

關聯