本地能跑、生产挂:系统化排查清单

本地 npm run dev 全绿,部署到 Vercel / Render / Cloudflare Pages 就 build fail 或功能全坏。先本地复现生产 build,再 diff 环境变量、锁 Node 版本、修 Linux 大小写敏感——一份系统化清单。

经典痛点:本地 npm run dev 一切正常,部署到 Vercel / Netlify / Cloudflare Pages / Render 后,要么 build fail、要么 runtime crash、要么页面能加载但功能全坏。“在我电脑上能跑”(works on my machine)是软件圈最老的梗,而根因几乎永远是同一类:本地的隐式假设在生产环境不成立

最快的修法(先做这个):在本地跑一次真正的生产 build——npm run build && npm start(Vite 项目用 npm run preview)。大约 90% 的”本地能跑、生产挂”都能在自己机器上一分钟内复现,这就说明它是 build 或代码 bug,而不是什么部署玄学。如果本地 build 和运行都干净、部署后仍挂,那差异就在环境上:按可能性排序是环境变量、Node 版本、Linux 大小写敏感。

这是 diff 的活,不是猜的活。按下面五个维度逐一过:env vars → runtime version → file system → network/CORS → build process。

先定位你属于哪一类

动手改之前,先用生产里的报错字符串对照最可能的原因。

生产日志 / 控制台里的报错最可能的原因跳转
Cannot read properties of undefinedInvalid URL、数据库连接被拒生产缺环境变量Step 2
X is not a functionSyntaxError: Unexpected tokenUnsupported engineNode/Python 版本不一致Step 3
Module not found / Cannot find module './Button'(本地 build 正常)Linux 大小写敏感Step 4
EROFS: read-only file system、对运行时写入的路径报 ENOENT依赖本地文件系统/内存状态Step 5
dev 正常,只在 build 后挂(白屏、hydration 报错)dev 服务器 vs 生产 buildStep 6
CORS 报错、401/403、cookie 没设上、OAuth 跳转对不上origin/cookie/key 差异Step 7
时间戳整体差 N 小时timezone/localeStep 8

常见原因(按命中率)

1. 环境变量在生产没设

最高频的单一原因。你本地 .env 里有 DATABASE_URLSTRIPE_SECRET_KEY 等,但部署平台从没收到这些值,于是运行时 process.env.Xundefined。还有个更隐蔽的变体:变量确实设了,但只设在了 Preview 环境而不是 Production(Vercel 的变量是按环境分作用域的);或者一个要暴露给浏览器的变量缺了必需的前缀(NEXT_PUBLIC_VITE_PUBLIC_),导致 client bundle 打包时根本没带上它。

如何判断:生产日志报 Cannot read properties of undefinedInvalid URL,或启动时数据库/Redis 连接被拒。

2. Node / Python 版本不一致

本地 Node 24、生产跑在更老的大版本上——或者反过来。截至 2026 年 6 月,各平台的默认版本都往上挪了,所以”生产卡在 Node 18”这个老假设现在通常已经不成立;但如果 .nvmrc 或项目设置里钉死了一个老版本,照样会咬人:

  • Vercel:提供 24.x(默认)、22.x20.x。Node 18.x 已弃用。
  • Cloudflare Pages:V3 build image 默认 Node 22(2025 年取代了原来的 Node 18 默认值)。
  • Render:新建 service 默认 Node 22.10.0;已有 service 保留创建时的版本。
  • Netlify:读取 .nvmrc / NODE_VERSION;当前默认跟随某个活跃 LTS。

ES2023+ 的语法和 API(Array.prototype.toSortedfindLast、某些配置下的 top-level await)在 Node 20+ 上存在、在 18 上不存在,所以仍钉死在 18 的 service 跑现代代码会直接崩。

如何判断:生产日志报 X is not a functionSyntaxError: Unexpected token,或平台直接以 Unsupported engine / EBADENGINE 拒绝 build。

3. 文件系统差异(大小写、路径分隔符)

你的笔记本在骗你。macOS(默认 APFS)和 Windows(NTFS)是大小写敏感的;而 Linux——所有主流生产环境跑的系统——是大小写敏感的。

// macOS / Windows:大小写不敏感,这能解析
import './components/Button'; // 磁盘上文件名是 button.tsx——本地照样能跑

// Linux 生产:大小写敏感,这会抛错
import './components/Button'; // Error: Cannot find module './components/Button'

同样的坑也会出现在资源路径(/Logo.png vs /logo.png),以及任何你在运行时读取的字符串路径上。

如何判断:生产 build 报 Module not found / Cannot find module,但在你的 Mac/Windows 上 npm run build 是干净的。

4. 依赖本地服务(文件系统、内存状态)

本地你用 fs.writeFile('./uploads/x.jpg') 存上传,或把缓存放在一个模块级变量里。部署到 serverless 平台(Vercel Functions、Cloudflare Workers/Pages Functions)后,文件系统除了 /tmp 之外都是只读的,而且每次调用可能命中不同的冷实例——于是内存状态会悄悄重置、写入直接抛错。

如何判断:报 EROFS: read-only file systemENOENT,或”上传成功了一次然后就 404”,或者缓存/会话莫名其妙清空。

5. dev 服务器和生产 build 行为不同

Vite / Next / Webpack 的 dev 模式不打包、带 HMR、解析宽松。生产 build 会 tree-shake、minify,并(Next/Astro 等)做服务端渲染。依赖 dev-only 行为的代码——读 import 的副作用、SSR 期间用 window、组件输出不确定——只会在 build 之后挂。

如何判断npm run dev 正常,但 npm run build && npm start 复现出白屏、React hydration mismatch,或 window is not defined 的 SSR 报错。

localhost:3000yourdomain.com 是不同的 origin,因此受不同的 CORS allowlist、不同的 cookie SameSite/Secure 行为(Secure cookie 在纯 http://localhost 上会被丢弃,除非你显式放开)、以及不同的 OAuth redirect-URI allowlist 约束。一个常见疏漏:dev 用了 provider 的测试 key,而 OAuth app 里注册的 redirect URI 只列了 localhost

如何判断:生产 Network 面板报 CORS error、401/403、“cookie 没设上”,或 dev 里从没出现过的 OAuth redirect_uri_mismatch

7. timezone / locale

new Date().toString() 按你笔记本的本地时区渲染;生产 server 跑在 UTC。于是所有服务端格式化出来的时间戳都会差你的 UTC 偏移量。

如何判断:生产里日期/时间一致地偏移了固定小时数。

最短修复路径

Step 1:本地复现生产 build

在动部署平台之前,先跑一次真正的生产 build:

# Next.js
npm run build && npm start

# Vite(React/Vue/Svelte SPA)
npm run build && npm run preview

# Astro / 通用 Node server
npm run build && NODE_ENV=production node ./dist/server/entry.mjs

如果这一步就挂了,那就是 build/代码问题、不是部署——在反馈最快的本地修掉。如果这一步干净,差异就在环境上,继续往下。

Step 2:diff env vars(可靠的做法)

别手动誊抄 dashboard。在 Vercel 上,把线上环境变量拉到本地文件,再跟你的 .env 对比:

# 把生产环境变量拉到 .env.production.local
vercel env pull .env.production.local --environment=production

# 都归一化成 KEY 名,比较各自存在哪些
comm -3 \
  <(grep -oE '^[A-Z0-9_]+' .env | sort -u) \
  <(grep -oE '^[A-Z0-9_]+' .env.production.local | sort -u)

打印在左侧的,就是本地有、生产没有的。把缺的补上:

vercel env add DATABASE_URL production
vercel env add STRIPE_SECRET_KEY production
# CLI 会提示你粘贴值(输入不回显)

然后重新部署——环境变量的改动不会作用到已有的部署上。几个高频坑的检查清单:

  • 设在了正确的环境(Production,而不只是 Preview/Development)。
  • 要暴露给浏览器的变量带上框架前缀:NEXT_PUBLIC_VITE_PUBLIC_(Astro/SvelteKit)。
  • 多行密钥(私钥)保留换行——直接粘贴,别手敲。

其他平台从 dashboard 核对:Render → service → Environment;Cloudflare Pages → Settings → Variables and Secrets;Netlify → Site configuration → Environment variables

Step 3:锁 runtime 版本

让本地、CI、生产统一到同一个 Node 大版本。在 package.json 和一个版本文件里都钉死:

// package.json——Vercel/Netlify/Cloudflare 都读 engines.node
{
  "engines": {
    "node": "22.x"
  }
}
// .nvmrc(也接受 .node-version)
22

截至 2026 年 6 月的各平台细节:

  • Vercel 部署你指定大版本的最新补丁版(如 22.x → 最新的 22)。engines.node 会覆盖 dashboard 里的设置。在 Build Command 里跑 node -v、或运行时打印 process.version 来验证。
  • Cloudflare Pages 按这个顺序解析:.nvmrc / .node-versionNODE_VERSION build 环境变量 → 默认(V3 image 上是 Node 22)。
  • RenderNODE_VERSION(环境变量)或 .node-version;接受 semver 或 lts 这类别名。

Step 4:修 Linux 大小写敏感

找出大小写跟磁盘文件对不上的 import:

# 列出相对 import,再跟实际文件名比对
grep -rEn "from '\.\./|from '\./" src/ | sort -u | head -50
ls -la src/components/

要可靠地自动抓到它,就在一个跟生产匹配的 Linux 容器里跑 build(一行 dry run,不必去折腾 APFS 大小写敏感分区):

docker run --rm -v "$PWD":/app -w /app node:22-slim sh -c "npm ci && npm run build"

如果这个 build 报 Cannot find module 而本地 build 通过,你就抓到了一个大小写 bug。用 eslint-plugin-importimport/no-unresolved)把它常驻 lint 住,并把文件名/import 改成完全一致。

Step 5:替换本地专属依赖

把任何”假设有可写、持久化本地机器”的东西,换成云原生的等价物:

本地用(在你机器上能跑)生产改用
fs.writeFile 写到 ./uploadsS3 / Cloudflare R2 / Cloudinary
内存缓存 / 全局变量Redis (Upstash) / Cloudflare KV
本地 SQLite 文件Postgres (Neon/Supabase) / Turso
本地 cron / setIntervalVercel Cron / Cloudflare Cron Triggers / Inngest
从本地文件读密钥平台环境变量 / 密钥管理服务

在 serverless 上,如果非要碰磁盘,只有 /tmp 可写,而且每次调用都是临时的。

Step 6:让 dev 和 prod 行为一致

不要按 NODE_ENV 分叉逻辑——那等于保证 dev 永远测不到 prod 的 bug:

// 反模式:dev 永远走不到 prod 路径
if (process.env.NODE_ENV === 'development') {
  // ...
}

// 更好:一个显式、可测试、能在 staging 翻转的 flag
const useMock = process.env.USE_MOCK === 'true';

然后用一个设了生产 flag 的 staging/preview 部署去跑、在那里验证,再 merge。对 SSR 框架,碰浏览器全局对象前要先守卫:if (typeof window !== 'undefined') 之后再用 window/document

Step 7:为生产 origin 对齐 CORS、cookie 和 OAuth

生产 origin(https://yourdomain.com)不是开发 origin(http://localhost:3000),所以一切按 origin 绑定的东西都得重新登记:

  • CORS allowlist:把生产 origin 加到服务端的 Access-Control-Allow-Origin(以及对应的预检配置),不能只有 localhost。如果要跨域带 cookie,得设 Access-Control-Allow-Credentials: true,并且用明确的 origin,而不是 *
  • Cookie:生产是 HTTPS,所以要设 Secure,并选好合适的 SameSite——跨站用 SameSite=None; Secure,同站用 Lax。一个在 http://localhost 上能用的 cookie,如果要求 Secure 但请求不是 HTTPS、或者 Domain 配错了,在生产就会被悄悄丢弃。
  • OAuth:把生产回调(https://yourdomain.com/api/auth/callback/...)加进 provider 的 redirect-URI allowlist,并把 provider 的测试 key 换成正式 key。

验证:打开生产站点,复现登录/请求流程,确认 Network 面板里没有 CORS 拦截、没有 redirect_uri_mismatch,且会话 cookie 带着预期的 Secure/SameSite 属性被设上了。

Step 8:统一用 UTC

// server 端:用 UTC ISO 字符串存储和传输
const iso = new Date().toISOString(); // 如 2026-06-21T08:00:00.000Z

// client 端:只在显示时转成用户时区
new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai' }).format(new Date(iso));

server 上也设 TZ=UTC,这样即便有零散的本地时间格式化,至少是一致的。

如何确认已修好

  1. 本地生产 build(npm run build && npm start)干净通过。
  2. vercel env pull 的 diff 显示生产没有缺任何 key。
  3. build 日志里的 node -v(或运行时的 process.version)在每个环境都匹配你钉死的大版本。
  4. Linux 容器 build(docker run … node:22-slim … npm run build)成功。
  5. 重新部署,再重新触发那个出问题的操作。生产日志里原来的报错字符串消失、浏览器控制台干净。

FAQ

为什么本地能 build、只在部署平台挂? 你的开发机大小写不敏感(macOS/Windows),而部署跑在大小写敏感的 Linux 上,而且它的环境变量跟你生产项目里的不一致。用一个 node:22-slim 的 Docker build 加一次 vercel env pull diff 同时复现这两点,差异通常立刻就冒出来了。

npm run dev 能跑、npm run build 挂——差在哪? dev 服务器不打包、解析宽松;生产 build 会 tree-shake、minify,并(SSR 框架)做服务端渲染。常见元凶:SSR 期间碰了 window/document、React hydration mismatch、以及只有打包器会丢掉的 import 副作用。在本地用 npm run build && npm start 复现。

我在 Vercel 上加了环境变量,但它还是 undefined。 环境变量的改动只对部署生效,所以要触发一次重新部署。再确认它设在了 Production(不只是 Preview),且任何 client 端变量都用了框架前缀(NEXT_PUBLIC_ / VITE_ / PUBLIC_)——少了它,值永远到不了浏览器 bundle。

怎么让所有地方的 Node 版本一致?package.json 里写 "engines": { "node": "22.x" },在 .nvmrc 里写 22。Vercel、Netlify、Cloudflare Pages 都读这两个。GitHub Actions 用 actions/setup-node@v4node-version-file: .nvmrc。每个 build 日志里用 node -v 验证。

我的时间戳在生产里正好差 N 小时。 生产 server 跑 UTC,你的笔记本不是。存储和传输都用 new Date().toISOString(),server 上设 TZ=UTC,只在显示时用 Intl.DateTimeFormat({ timeZone }) 转成用户时区。

预防建议

  • 每次推 PR 前都跑一次 npm run build && npm start——别只信 dev server。
  • zod(或类似工具)在 boot 时校验环境变量,缺 key 时立刻 fail-fast 给出清晰报错,而不是在请求深处崩掉。
  • package.json 里钉死 engines.node,并配一个匹配的 .nvmrc;CI 用 actions/setup-node@v4node-version-file: .nvmrc
  • Mac 用户:在 Linux 容器(node:22-slim)里跑 build,在部署前就暴露大小写敏感 bug。
  • 任何”只在本地能用”的代码(fs.write、内存状态)从 day 1 就用云服务替代。
  • 维护一个配置跟 prod 一致的 preview/staging 环境,每个 PR merge 前先在那里验证。
  • 时间一律存 UTC ISO 字符串,只在显示层转 timezone。
  • 全团队统一一个 .nvmrc / .python-version,彻底告别”我电脑能跑”。

相关阅读

标签: #后端 #排查 #排查