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>各自跟随)。