一次真实的部署记录:一个为单机服务器写的 Next.js 博客,如何改造成 Serverless 架构,免费跑在全球 CDN 上。全文包含每一处踩坑和解决办法。
一、这套组合为什么值得
个人博客的部署选项很多:买台云服务器、用 WordPress、扔到静态托管。但如果你写的是一个全栈项目——有数据库、有后台管理、有用户评论——那选择就窄了。
我这次用的是:
GitHub:代码仓库,私有仓免费
Vercel:托管 Next.js 项目,Hobby 计划免费,自带全球 CDN 和自动 HTTPS
Turso:托管的 SQLite(libSQL),免费档 500MB 存储,对个人博客绰绰有余
Cloudflare:管理域名 DNS,免费
四样加起来,月成本为零。而且推送代码到 GitHub,Vercel 自动构建部署,整条流水线全自动。
但「免费」的代价是:Vercel 是 Serverless 架构,它和传统单机部署的假设完全不同。如果你的代码是照着「一台服务器跑 next start」写的,直接部署必挂。下面就是我把一个这样的项目搬上线的完整过程。
二、先泼冷水:Serverless 的三条铁律
Vercel 的运行环境有三条铁律,对照你的项目,任何一条不满足都会出问题:
铁律一:文件系统只读,且不持久。 除了 /tmp(实例销毁即消失),任何路径都写不进去。本地 SQLite 文件、用户上传的图片、落盘的备份——全部失效。
铁律二:没有常驻进程。 函数只在请求期间存活,请求结束实例就可能被回收。setInterval 定时器、后台 Worker、内存缓存——统统不可靠。
铁律三:多实例不共享内存。 同一个函数会冷启动出多个实例。这次请求在 A 实例写了内存 Map,下次请求落到 B 实例,什么也读不到。
我的博客项目三条全中:SQLite 在本地 data/blog.db,邮件队列靠 setInterval Worker 消费,评论验证码存在内存 Map 里。下面逐个拆解。
三、数据库上云:一次导入踩了三个坑
SQLite 不能带上去,我选了 Turso——它是托管的 SQLite,Prisma 的 schema 一行都不用改(provider = "sqlite" 保持原样),只需要加一个驱动适配器:
// src/lib/prisma.ts
import { PrismaLibSQL } from "@prisma/adapter-libsql";
import { createClient } from "@libsql/client";
const url = process.env.TURSO_DATABASE_URL;
if (url) {
// 线上:走 Turso
const adapter = new PrismaLibSQL(createClient({ url, authToken }));
return new PrismaClient({ adapter });
}
// 本地开发:回退本地文件,不依赖网络
return new PrismaClient();
这段代码同时解决了本地开发的问题:没配 Turso 环境变量时自动回退本地 SQLite,两边互不干扰。
然后是数据迁移,三个坑挨个踩:
坑一:官方导入命令被拦。 turso db import data/blog.db 在我的网络环境下直接被 CloudFront 403 拒绝。解决办法是绕开它:turso db create 建空库,再用 turso db shell 灌 SQL。
坑二:.dump 导出的 SQL 用不了。 macOS 新版 sqlite3(3.54)的 .dump 会用 unistr() 函数转义特殊字符,Turso 的 SQLite 不认识这个函数,整批报错。最后我用 Python 逐行读取、自己生成 INSERT 语句,转义完全可控。
坑三:外键顺序。 第一次按字母序导表,Attachment 排在 User 前面,外键约束直接报错。需要在开头加 PRAGMA foreign_keys=OFF,并按依赖顺序(用户 → 文章 → 评论)插入。
另外一个容易忽略的点:Prisma 的 generator 要加 binaryTargets = ["native", "rhel-openssl-3.0.x"]。Vercel 构建机是 Amazon Linux,不加的话,node_modules 缓存命中本地产物后,线上会报「找不到查询引擎」。
四、给 Vercel 做体检:三类必须改的代码
1. 构建期查库——最隐蔽的一个
部署后第一次构建就失败了,报错在 robots.txt。排查后发现:Next.js 的静态生成发生在 next build 阶段,而 Vercel 的构建机上没有数据库。
我的根布局 generateMetadata() 要读站点设置表,sitemap.ts 和 robots.ts 用了 ISR(revalidate = 3600,首次渲染在构建时)——这些都会在构建期连数据库,直接把构建打挂。
改法分三档:
页面和布局:加
export const dynamic = "force-dynamic"Route Handler 的 GET:
force-dynamic对它无效(Next 14 仍会静态化),要改用export const revalidate = 0robots.ts:干脆不查库,域名直接读环境变量SITE_URL
改完的验收标准很硬核:把 .env 和数据库文件都移走,npm run build 必须成功。能做到这一点,Vercel 构建就不会再因数据库出问题。
2. 内存状态——评论验证码死循环
评论功能有个邮箱验证码,原实现存在进程内存的 Map 里。在单机上没问题,但在 Vercel 上:发验证码和提交验证码是两次请求,几乎必然落到不同实例。用户会陷入死循环——明明收到了验证码,提交时却提示「请先发送验证码」。
这类问题在本地永远测不出来。改法是新建一张 VerifyCode 表,验证码落库,跨实例自然就一致了。
3. 文件存储——上传图片改为 Vercel Blob
原来上传图片写本地目录,现在改为 @vercel/blob 的 put(),返回公开直链,连自建的图片读取路由都删掉了。有两个细节:
Vercel 对请求体的硬限制是 4.5MB,所以应用层上限要设得比这小(我设了 4MB)
新建的 Blob 存储默认走 OIDC 认证,只注入
BLOB_STORE_ID,不再需要传统的BLOB_READ_WRITE_TOKEN——网上很多旧教程在这一步会把你带偏
4. 定时任务——免费版的硬约束
邮件队列原来靠常驻 Worker 消费。Vercel 上没有常驻进程,唯一的官方替代是 Vercel Cron——但 Hobby 计划的 Cron 一天只能跑一次,且更频繁的表达式会在部署时直接被拒绝。
而密码重置链接 30 分钟就过期、评论验证码 10 分钟就过期,等不了第二天的 Cron。所以我把邮件分成两类:时效性邮件(验证码、重置密码)改为请求内同步发送;批量推送(新文章通知订阅者)才走队列,由每日 Cron 兜底重试。这是免费方案下唯一正确的分法。
五、部署上线:比想象中顺滑
代码改造完,部署反而是最简单的一步:
vercel link # 建项目并关联 GitHub 仓库
# 在控制台或 CLI 配好环境变量
vercel --prod # 首次部署
之后每次 git push,Vercel 自动构建部署,一次构建约 50 秒。环境变量在三个环境(Production / Preview / Development)各配一份,重点是这几个:
变量 说明
TURSO_DATABASE_URL / TURSO_AUTH_TOKEN 数据库连接
AUTH_SECRET / EMAIL_SECRET 各自独立生成,后者不足 32 位会直接报错
SITE_URL 不设的话,邮件里的链接会静默指向 localhost
注意 SITE_URL 要改两个地方:环境变量管 robots.txt(构建时读取,改完要重新部署),数据库里的 siteUrl 字段管 sitemap 和邮件链接(请求时读取,改完立即生效)。只改一处会出现「半新半旧」的诡异状态。
六、Cloudflare 接入域名:一个开关定成败
最后把域名 blog.995885.xyz 接上。Vercel 那边 vercel domains add 之后,它会告诉你需要加的 DNS 记录,我的是一条 CNAME。
关键在 Cloudflare 侧的那个代理状态开关:
橙云(Proxied):流量经过 Cloudflare 代理。Vercel 无法完成域名验证、签不出 SSL 证书,常见症状是无限重定向或证书错误
灰云(DNS only):Cloudflare 只做解析,流量直达 Vercel,SSL 由 Vercel 自动签发(Let's Encrypt)和续期
个人博客直接选灰云。 代价是放弃 Cloudflare 的 CDN 加速,但 Vercel 本身就有全球边缘网络,这个代价基本为零。如果一定要橙云,需要把 Cloudflare 的 SSL 模式调成 Full (strict),并且做好排查证书链的心理准备。
另外不需要按 Vercel 提示把 Nameserver 改成它的——保留 Cloudflare 管 DNS,加一条记录就够了。DNS 生效很快,灰云状态下几分钟内 SSL 就签好了。
七、踩坑清单速查
坑 症状 解法
构建期查库 next build 阶段报数据库连接错误 布局加force-dynamic;API 路由用 revalidate = 0
Route Handler 静态化 加了force-dynamic 仍被静态化 Next 14 对 GET Handler 要用revalidate = 0
内存 Map 状态 功能时好时坏,本地正常线上失灵 落库;跨实例一致性只能靠共享存储
常驻 Worker 不跑 队列积压、定时任务不执行 时效任务同步做;批量任务用 Cron
Hobby Cron 限制 高频 cron 表达式部署被拒 免费档一天一次,架构上就不能依赖高频任务
请求体 4.5MB 上限 上传大文件 413 应用层上限设 4MB,留出表单开销
turso db import 403 上传被 CloudFront 拦截 建空库 + SQL 灌入
.dump 含 unistr() Turso 报「no such function」 用脚本自己生成 INSERT
Prisma 引擎缺失 线上报找不到 query engine binaryTargets 加 rhel-openssl-3.0.x
八、免费额度到底够不够用
很多人担心「免费的东西迟早收费」或「流量一大就超额」。以个人博客的真实量级算一笔账:
服务 免费额度 博客实际消耗
Vercel Hobby 100GB 带宽/月、Serverless 函数按需 个人博客月 PV 一万以内,带宽消耗通常不到 10GB
Turso 500MB 存储、每月 5 亿行读 我的全量数据 284KB,连 1% 都用不到
Vercel Blob 1GB 存储(Hobby) 一张配图几十 KB,几千张才接近上限
Cloudflare DNS 免费,不限解析次数 灰云模式不消耗任何付费功能
真正会触顶的是两种情况:图片总量超过 1GB(解法:图床单独放,或升 Blob 付费档),或者站点被恶意刷流量(解法:Cloudflare 切橙云 + 开防火墙规则,这是免费层最硬的防护手段)。
还有一个隐性收益值得说:Vercel 的 Preview 部署。每次提 PR 自动生成一个预览 URL,改完样式先在预览环境看效果,确认无误再合并——这个工作流用过就回不去了。
九、写在最后
最终架构:GitHub push → Vercel 自动构建(约 50 秒)→ 全球 CDN 分发,数据在 Turso(东京区),图片在 Vercel Blob,域名解析在 Cloudflare。整站月成本为零,免费额度对个人博客完全够用。
这次部署最大的体会:Serverless 迁移的核心不是「换个部署平台」,而是审视代码里所有对「单机、常驻、本地盘」的隐含依赖。文件路径、内存状态、定时任务、构建期副作用——这四样在本地开发时天经地义,上了 Serverless 全是雷。
好在雷都有排法。祝你部署顺利。
评论
登录之后,留下你的话。登录·注册