个人博客部署踩坑全记录: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 里创建 client → client-<hash> 的副本。
我把这个逻辑也加到了构建后脚本里,彻底自动化了。
教训总结
这次部署教会我的几件事:
第一,开发环境和生产环境永远有差异。SQLite 的字符串长度无限制,MySQL 的 VARCHAR 默认 191 字符——这种差异在本地开发时完全感知不到,只有上了生产环境才会暴露。
第二,不要假设工具"应该"懂你的意图。standalone 模式不复制 static/ 和 public/,NextAuth 不信任 IP 地址,server.js 不读 .env——这些都不是"bug",而是有意为之的设计。工具给了你灵活性,代价是你需要自己理解并适配。
第三,环境变量是最容易出错的环节。.env 文件、dotenv 包、PM2 的 env 配置、server.js 的加载时机——这四者之间任何一个衔接断了,结果都是运行时错误,而且错误信息往往和真正的根因无关。
第四,能本地 build 就不要在服务器上 build。服务器的环境复杂且不可控,与其在上面调试构建问题,不如在本地完成所有构建工作,把最终产物直接丢上去。这就是 standalone 模式存在的意义。
第五,好的错误信息是排查的关键。整个部署过程中,有些错误 5 分钟解决(因为报错信息很明确),有些折腾了几个小时(因为报错跟根因完全不沾边)。这个差距,就是"工具质量"的体现。
以上就是我的个人博客从本地开发到线上部署的全部经历。如果你也在做类似的事情,希望这些记录能帮你少走一些弯路。