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() 解析順序:
X-Forwarded-Host優先 → 沒有則用HostX-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:
- Host header 行為:cloudflare 預設會把客戶端原始 Host 改成你的 origin 網域(在 Network → Origin Server Properties → "Host Header" 決定)。建議設為 "Preserve original Host header",讓 nginx 拿到的也是使用者輸入的網域
- SSL/TLS mode:必須 "Full (strict)",否則 cloudflare 可能不發送 X-Forwarded-Proto
- 多副網域 + 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>各自跟隨)。