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

关联