MMyBlog
← 返回博客
部署
12 分钟·4,599 ·24 次阅读

个人博客部署踩坑全记录:Next.js + Prisma + 宝塔面板

背景

这个博客从本地开发到线上部署,总共花了我一整天。

不是夸大。早上 9 点我开始动手,想着"不就是把 Next.js 项目部署到服务器上嘛,半个小时够了"。结果折腾到晚上 8 点才看到浏览器里出现我写的页面。这篇文章就是这一天的完整记录。

服务器环境

租的是一台阿里云 ECS,2 核 4G 内存,40G SSD。系统是 CentOS 7,上面装了宝塔面板。数据库用的是宝塔自带的 MySQL 管理器——一键创建数据库、创建用户、授权,用 127.0.0.1 本地连接。

整个部署流程的逻辑其实很简单:

本地开发机                    服务器
   │                           │
   │ ① npm run build           │
   │ ② tar 打包 standalone     │
   │                            │
   │──────③ scp 上传 ─────────→│
   │                            │
   │                            │ ④ tar 解压
   │                            │ ⑤ 创建 .env
   │                            │ ⑥ PM2 启动 server.js
   │                            │ ⑦ Nginx 反向代理到 3000 端口
   │                            │
   │←───── ⑧ 浏览器访问 ──────│

看起来简单,实际上每一步都有意料之外的问题。

坑一:MySQL TEXT 列映射,文章内容被截断

第一个问题出现在数据迁移环节。我在 Prisma schema 里把数据库从 SQLite 切到 MySQL,用 prisma db push 推送表结构。看起来一切正常,seed 脚本也能跑,但文章内容只存了前面一两百个字——后面的全被截断了。

排查过程是这样的:先看 seed 的输出,没有报错;再看数据库,内容确实不完整;回想 Prisma 文档,发现本地 SQLite 和远端 MySQL 的默认字段类型不一样——SQLite 没有 VARCHAR 长度限制,但 MySQL 默认的 VARCHAR(191) 最多只能存 191 个字符(utf8mb4 编码下每个字符占 4 字节)。

解决方法是把所有长文本字段在 Prisma schema 里显式声明为 TEXT 或 LONGTEXT:

model BlogPost {
  content  String  @db.LongText   // 长文章内容
  excerpt  String? @db.Text       // 摘要
}

model Project {
  description String? @db.Text    // 项目描述
}

然而 TEXT 字段在 MySQL 中不能设置默认值。Schema 中原来写的 @default("") 需要全部去掉。改了 Schema 后重新 push,Prisma 因为字段类型变更报错——因为 MySQL 不允许直接 ALTER VARCHAR 到 TEXT 类型的列。

最终只能 prisma db push --force-reset 重建数据库,再重新 seed。好在当时只有 seed 数据,没有线上用户数据。

坑二:Standalone 模式,CSS 全部 404

第二个问题更诡异。页面能打开,HTML 结构也完整,但整个页面就是白底黑字——没有任何样式。打开浏览器开发者工具一看,所有 CSS 和 JS 文件返回 404。

这个问题的根源是 Next.js standalone 模式的一个设计行为:构建产物不会自动包含 .next/static/ 和 public/ 目录。这些静态文件在你本地开发时由 Next.js dev server 自动托管,但在 standalone 模式下需要手动复制到部署目录。

解决方案也很直接:写一个 post-build 脚本,在 next build 完成后把 static 和 public 复制进 standalone:

// scripts/copy-standalone-assets.cjs(简化版)
function copyDir(src, dst) {
  // 递归复制目录
}

copyDir('.next/static', '.next/standalone/.next/static');
copyDir('public', '.next/standalone/public');

把这个脚本挂到 package.json 的 build 命令后面,以后 npm run build 就能自动完成这一步。

坑三:NextAuth 登录报 UntrustedHost

第三个问题卡我最久。登录页能打开,但输入账号密码点击登录后,返回了一个"服务器配置错误"的提示。

之前的错误信息很模糊,翻 GitHub Issues 才发现 NextAuth v5 有一个安全机制:默认不信任裸 IP 地址。你通过 http://116.62.60.53 访问,NextAuth 就认为这是"不受信任的主机",拒绝处理任何认证请求。

在 auth.ts 里加一行 trustHost: true 解决了这个问题。但又出现了新的问题:改了 auth.ts 并重新部署后,登录还是报错。

这次排查过程更曲折。最终发现问题出在环境变量的加载时机上:standalone 的 server.js 是 Next.js build 时生成的,它不会主动读取 .env 文件。所以 DATABASE_URL、NEXTAUTH_SECRET 这些关键的环境变量全是 undefined。

解决方法是修改构建后脚本,在 server.js 的头部注入一行:

require('dotenv').config();

并且把 dotenv 这个 npm 包复制进 standalone 的 node_modules。这样 server.js 启动后第一件事就是加载 .env,所有环境变量都可用。

坑四:服务器 npm install 完全残废

这个问题是我在尝试"直接在服务器上 git pull + npm install + npm build"的部署方式时遇到的。

宝塔的 Node.js 管理器把 npm 的前缀路径设到了全局路径下(/www/server/nodejs/v24.19.0/),导致执行 npm install 时,npm 会尝试把包安装到全局目录,而不是项目的 node_modules。

更糟糕的是,这个全局路径的组合导致了 npm 既不会报错,也不会真的把包装到项目里——它只是输出 "up to date",但 node_modules/.bin 里面什么都没有。

这个问题的根因在宝塔 Node 管理器的配置上,属于"不可控因素"。所以我放弃了服务器端 install 和 build 的方案,改用本地 build + 打包 standalone + scp 上传的流程。这样构建环境完全可控,服务器只负责运行。

坑五:Prisma 7 + Turbopack 的 hash-module 缺失

这是部署过程中遇到的最隐蔽的问题。前面所有问题都解决之后,改了 auth.ts 的那一版部署上去,首页直接 500。错误日志写着:

Cannot find module '@prisma/client-2c3a283f134fdcb6/runtime/client'

这个 hash 目录 client-2c3a283f134fdcb6 在服务器的 node_modules 里根本不存在。它是 Turbopack 在 build 时为 Prisma client 生成的唯一标识,build 产物的 SSR chunk 会引用这个 hash 路径加载 Prisma client。

但 standalone 模式只会把 node_modules/@prisma/client 标准路径包含进去,不会创建 hash 目录。解决方法是:从 build 产物目录的 .nft.json 文件中提取 hash 目录名,然后在 standalone 的 node_modules 里创建 clientclient-<hash> 的副本。

我把这个逻辑也加到了构建后脚本里,彻底自动化了。

教训总结

这次部署教会我的几件事:

第一,开发环境和生产环境永远有差异。SQLite 的字符串长度无限制,MySQL 的 VARCHAR 默认 191 字符——这种差异在本地开发时完全感知不到,只有上了生产环境才会暴露。

第二,不要假设工具"应该"懂你的意图。standalone 模式不复制 static/ 和 public/,NextAuth 不信任 IP 地址,server.js 不读 .env——这些都不是"bug",而是有意为之的设计。工具给了你灵活性,代价是你需要自己理解并适配。

第三,环境变量是最容易出错的环节。.env 文件、dotenv 包、PM2 的 env 配置、server.js 的加载时机——这四者之间任何一个衔接断了,结果都是运行时错误,而且错误信息往往和真正的根因无关。

第四,能本地 build 就不要在服务器上 build。服务器的环境复杂且不可控,与其在上面调试构建问题,不如在本地完成所有构建工作,把最终产物直接丢上去。这就是 standalone 模式存在的意义。

第五,好的错误信息是排查的关键。整个部署过程中,有些错误 5 分钟解决(因为报错信息很明确),有些折腾了几个小时(因为报错跟根因完全不沾边)。这个差距,就是"工具质量"的体现。


以上就是我的个人博客从本地开发到线上部署的全部经历。如果你也在做类似的事情,希望这些记录能帮你少走一些弯路。

文章链接: