Next.js 部署策略:Vercel vs 自托管
Next.js 部署不只 Vercel 一种——自托管(Docker/Node/Static)、边缘、混合各有取舍。本文讲清 Next.js 的几种部署模式(Vercel 托管、Docker standalone、静态导出、自建 Node)、各自的适用与代价,以及 NEXT_PUBLIC 环境变量在 build vs run 的坑。
本文要点
- 主题聚焦:Next.js、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
"Next.js 部署不就是 vercel 命令一键吗?"——这是我带过的好几个新人最初的想法。直到某天产品要上私有云、合规要求自托管、或者账单上 Vercel 的带宽费突然飙到五位数,团队才开始认真研究:除了 Vercel,Next.js 还能怎么部署?这一查往往踩一鼻子灰:为什么同一个镜像换到测试环境,NEXT_PUBLIC_ 的 URL 没变?为什么多实例部署后 ISR 缓存忽新忽旧?为什么 Server Actions 报 "Failed to find Server Action"?
这些问题没有一个是 Next.js 的 bug,全是"部署模式"没选对、没配对的后果。Next.js 16 这版把自托管的能力收紧也更清晰了:output: 'standalone' 产出最小 server.js、多实例下强制要求共享加密键和 deploymentId、Proxy(原 middleware)依赖 Edge runtime。本文按"你用了什么特性 → 该选哪种部署"的思路,把四种主流模式讲清,并给出一份能直接抄的 Dockerfile。
一、部署模式取决于你用了哪些特性
脱离特性谈部署就是空谈。先把特性映射到"必须有服务端"还是"可以纯静态"理清楚,选型就八九不离十:
| 特性 | Vercel | Docker standalone | 自建 Node (next start) | 静态导出 (output: export) |
|---|---|---|---|---|
| Server Components(动态) | ✅ | ✅ | ✅ | ❌(只能 build 时跑) |
| Route Handlers / API | ✅ | ✅ | ✅ | ❌ |
| Server Actions | ✅ | ✅ | ✅ | ❌ |
| ISR(增量静态再生) | ✅ 开箱 | ⚠️ 需配缓存 | ⚠️ 需配缓存 | ❌ |
| Edge / Proxy(原 middleware) | ✅ | ✅(Edge runtime) | ✅ | ❌ |
| 纯 SSG / SPA | ✅ | ✅ | ✅ | ✅ |
一句话:只要你在运行时需要一台 Node 进程(SSR/ISR/Server Actions/Route Handlers),就不能用静态导出;反之,纯展示站、文档站、后台管理面板,静态导出加个 CDN 是最便宜的解法。
二、Vercel 托管:零配置的代价是锁定
Vercel 是 Next.js 的"一等公民"——本来也是一家。git push 即部署,Edge Network 全球加速,ISR、PPR、Image Optimization、Edge runtime 全部开箱即用,预览环境、流量影子、瞬间回滚都在控制台点一下。
但"零配置"的另一面是平台锁定和按量计费:
- 特性锁定。
next/after、PPR(部分预渲染)、某些缓存指令在 Vercel 之外要么滞后要么不支持。深度用了这些,迁走就是重写。 - 价格模型。Vercel 按 Bandwidth、Edge Function 执行次数、Image Optimization 次数、Serverless Function 执行时长计费。一个图片多、流量大的 C 端站点,账单很容易失控——这也是很多团队在 DAU 起来后被迫迁走的主因。
- 合规与数据驻留。金融、医疗、政企场景要求服务器在自有机房或指定地区,Vercel 的 SaaS 模式直接出局。
适用:快速上线、全特性、团队不想碰运维。不适用:成本敏感的高流量站、私有云/合规、想长期保持可移植性。
三、Docker standalone:自托管的主流选择
output: 'standalone' 是 Next.js 12 起的自托管标配。开启后 next build 会在 .next/standalone/ 产出一个自带 server.js、只含必要 node_modules 的最小运行时——不需要在容器里装全套依赖,镜像能压到一百多 MB。
# --- 构建层 ---
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && corepack prepare [email protected] --activate \
&& pnpm install --frozen-lockfile
COPY . .
# ⚠️ NEXT_PUBLIC_* 在 build 时内联,必须用 build-arg 注入
ARG NEXT_PUBLIC_SITE_URL=https://example.com
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
RUN pnpm build
# --- 运行层 ---
FROM node:22-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -S nodejs && adduser -S nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
注意三个文档明确要求的细节:一是 public 和 .next/static 不会自动进 standalone,得手动 COPY;二是官方强烈建议前面挂一层 nginx 做反代,挡掉恶意请求、限流、TLS;三是端口和地址要通过 PORT / HOSTNAME 环境变量传给 server.js,默认监听 0.0.0.0:3000。
这套模式的代价:ISR 缓存、TLS、反代、日志、滚动发布的版本一致性,都得你自己管(见第六、七节)。还有个实战小技巧:构建期不需要真数据库就能 build。这个仓库自己的 Dockerfile 就在 build 阶段塞了一个 DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy"——因为 ISR 页面会延迟到生产首次请求时才渲染,构建不连库也能过。这种"构建期解耦数据源"的思路,是把镜像做成环境无关的关键一步。
适用:要自控、要省钱、要私有云——目前大部分自托管团队的首选。
四、静态导出:CDN 就够,代价是失去服务端能力
output: 'export' 让 next build 直接产 out/ 目录,里面是纯 HTML/CSS/JS。把它扔到 Cloudflare Pages、S3+CloudFront、Nginx、甚至 GitHub Pages,CDN 托管,零服务器成本,全球加速。
Next.js 16 里,Server Components 在静态导出时会在 build 期间执行(类似传统 SSG),产出静态 HTML 和客户端导航 payload。这意味着你仍然可以写 async Server Component 在构建期取数据——只要它不碰 cookies/headers 这类运行时 API。
但失去的是一整套运行时能力:Route Handlers、Server Actions、ISR、动态重写、Proxy 全部不可用。表单提交、用户登录、按需内容更新都得自己接一个独立后端。Image Optimization 也得换成自定义 loader 或外接服务。
适用:文档站、营销页、纯 SPA 后台、内容更新频率低。不适用:任何需要运行时服务端逻辑的产品。很多人想把动态站强行静态导出,最后不得不架一层 BFF 补能力,得不偿失。
静态导出还引出一种实战中很香的混合架构:前端用 Next.js 静态导出托管在 CDN(便宜、全球加速、扛得住流量峰值),所有动态逻辑(登录、支付、数据写入)拆给一个独立的 API 服务——可以是 NestJS、Go、甚至另一个跑在容器里的 Next.js。两边各自演进、各自扩容:前端改个样式不用重部后端,后端修个 bug 不用重 build 前端。代价是跨域、鉴权传递、部署流程变两套。团队小、产品简单时不值得;但一旦前后端节奏对不上、或前端流量远大于后端,这种解耦能省掉大量协调成本。
五、自建 Node(next start):传统 SSR 的灵活与负担
不开 standalone、保持完整 node_modules,next build 后用 next start 起服务——这是 Pages Router 时代最经典的部署方式,现在依然有效。它和 standalone 本质同源(都是跑 Node 服务),区别只是镜像更胖、要装全套依赖。
这种模式适合单机长驻、对镜像体积不敏感、需要本机装原生扩展(比如自己装的 sharp、数据库驱动)的场景。代价是一台完整服务器要你管:进程守护(pm2/systemd)、TLS 证书续期、日志轮转、OOM 处理、安全补丁。除非有特殊理由,新项目我更推荐直接上 standalone——少装东西就少出错。
六、最大的坑:NEXT_PUBLIC_* 是 build 时内联,不是 run 时
这是我见过最高频的部署错误,没有之一。很多人按"环境变量=运行时读取"的直觉,在 docker run 时传 -e NEXT_PUBLIC_API_URL=...,期望容器里跑的是新值。结果生产环境还在请求 staging 的 API。
机制是这样:NEXT_PUBLIC_* 变量在 next build 时会被字符串替换(hard-coded inline)进发给浏览器的 JS bundle。构建一旦完成,这些值就冻结在产物里,运行时怎么改环境变量都没用——浏览器拿到的是已经写死的字面量。
官方文档说得直白:用一个 Docker 镜像提升到多个环境时,所有 NEXT_PUBLIC_ 变量都被冻结在构建时的值。解决办法只有两条:
- 换环境就重新 build。在 CI 里用
--build-arg NEXT_PUBLIC_API_URL=...把值在构建期注入,每个环境一个镜像(本文 Dockerfile 示例就是这做法)。 - 改成运行时注入。把客户端需要的配置走一个
/api/config接口,在 Server Component / Route Handler 里读process.env(这是运行时值),再返给浏览器。这样一份镜像能跑所有环境,代价是多一次请求。
记住这条分界:不带 NEXT_PUBLIC_ 前缀的变量是服务端运行时读取的,可以镜像复用;一旦加了前缀,就是 build 时冻结,换环境必须重 build。
七、ISR 在自托管:每实例独立缓存是隐形炸弹
ISR 的缓存默认存在每个 Next.js 实例的本地磁盘上。单实例长驻,一切正常。一旦你横向扩展到多容器(K8s 多 Pod、负载均衡多实例),麻烦就来了:每个实例有自己一份独立缓存,revalidateTag / revalidatePath 调用只让当前实例失效,其他实例继续吐旧数据——用户刷新一下页面,内容在"新"和"旧"之间反复横跳。
Next.js 16 给的官方解法是三件套:
- 自定义 cacheHandler,把缓存后端从本地磁盘换成共享存储(Redis 或 S3),并设
cacheMaxMemorySize: 0关掉内存缓存。 - 共享加密键。多实例必须设同一个
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY(base64、16/24/32 字节 AES 键),否则 Server Actions 跨实例解密失败,报 "Failed to find Server Action"。 deploymentId防版本错位。滚动发布时新旧实例共存,客户端可能请求到已删除的静态资源。配deploymentId: process.env.DEPLOYMENT_VERSION,新版客户端会自动触发整页刷新,而不是软导航报错。
换句话说,自托管多实例不是"起多份就完事"——它是一套需要显式协调的分布式系统。单实例能扛就先单实例,真要扩容,Redis 共享缓存这一步省不掉。
小结
部署选型的核心,是先回答两个问题:你用了哪些运行时特性?你的环境允不允许平台锁定?
- 特性决定模式。有 SSR/ISR/Server Actions 就必须有 Node 进程,静态导出直接排除。
- Vercel 适合快速上线和全特性,但带宽、Function 计费会随规模变贵,深度用 PPR/Edge 会锁死平台。
- Docker standalone 是自托管主流,镜像小、
server.js自带,但反代/TLS/缓存协调全得自己来。 - 静态导出最便宜,CDN 托管零服务器,代价是失去一切运行时服务端能力。
NEXT_PUBLIC_*是 build 时冻结,不是 run 时——换环境不重 build,就是生产请求 staging 的经典事故源头。- 多实例自托管必须共享缓存和加密键,否则 ISR 忽新忽旧、Server Actions 跨实例报错。
金句:部署模式不是"选哪个工具",而是"为你的特性集和可控边界,挑一份你能长期承担的运维代价"。 Vercel 用钱换省心,自托管用心力换自控——没有免费的午餐,只有被推迟到哪天爆雷的账单。
Related