文中涉及的官方文档:Next.js 自托管SerwistPM2 配置项postgres 镜像说明

最近连着给三四个 Next.js 项目做自托管,这里记录下踩过的几个坑捏。功能部分 Claude 写得是真快,页面、tRPC 路由、Prisma schema 一口气糊出来都能跑;可一到部署收尾就开始反复,同一个文件改回来改过去。下面全是「build 通过但跑不起来」那一类,本地一点征兆都没有…

不废话了,干正事!

standalone 多阶段镜像:只有一条是真踩了坑

本来想标题写「四个坑」的,结果翻 git 记录被打脸了~ vns-next 的 Dockerfile 全生命周期只有 4 笔提交动过它:404a769 创建,之后 0b7117c037ccf4fefd366 各改一次。而下面这几条里有三条在首次提交就已经写对了,压根没「踩」过。

先看真正踩了的那条。404a769 里的构建基底原本是纯 oven/bun 镜像,跑起来直接挂,0b7117c 才补上 node:

# 注意:不能用纯 oven/bun 镜像——prisma/next 的 CLI shebang 是 #!/usr/bin/env node,
# 构建环境需要 node;bun 只作为包管理器/脚本运行时拷入
ARG REGISTRY_MIRROR=docker.io
FROM ${REGISTRY_MIRROR}/oven/bun:1 AS bun-dist
FROM ${REGISTRY_MIRROR}/library/node:24-bookworm-slim AS builder
COPY --from=bun-dist /usr/local/bin/bun /usr/local/bin/bun

oven/bun:1 镜像里压根没有 node,prisma generate 一跑就挂,但错误信息只说 CLI 找不到解释器,不看 shebang 根本想不到。最后做法是 node:24-bookworm-slim 当基底,只从 bun 镜像里 COPY 一个二进制过来当包管理器就行。

剩下三条是首次提交里就写好的,摘几段关键的(原文件里没有编号,是我分开贴的):

# Dockerfile:23-24 —— prisma.config.ts 里那句 if (!url) throw 是无条件抛错的,
# 而 generate 根本不连库,所以构建期得塞个占位值
ENV SKIP_ENV_VALIDATION=1
ENV DATABASE_URL="postgresql://build:build@localhost:5432/build"
# Dockerfile:26-28 —— NEXT_PUBLIC_* 在构建期内联进客户端 bundle,
# 改站点域名必须重新 build,所以要走 ARG 而不是运行时环境变量
ARG NEXT_PUBLIC_APP_URL=https://example.com
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
# Dockerfile:49 —— standalone 的文件追踪可能漏拷 Prisma 生成的客户端资产(含 wasm/schema)
COPY --from=builder --chown=nextjs:nodejs /app/src/generated ./src/generated

NEXT_PUBLIC_* 那条我觉得最容易被忽略:它不是运行时变量,在 next build 那一刻就被字面量替换进客户端产物里了。deploy/README.md:153-154 我特意又写了一遍——改了这类变量必须 --build 重建镜像,光改 .env 重启容器是没用的哦。

第二次真踩坑是在 b07e20d:builder 那一层里没有数据库容器,而 Next 默认会在 next build 阶段把查库页面静态预渲染一遍,必然抛错。这个坑属于渲染模式而不是镜像本身,我在另一篇里连修法带验证手段一起细说过;这里只提一句它对 Dockerfile 的意义——它是唯一一个「Dockerfile 一个字没错,但 build 就是过不去」的坑,你盯着多阶段构建看一整天也找不出毛病。

compose 这边:镜像源来回两趟,端口一路收敛

镜像源那事挺好笑的。037ccf4 把基础镜像全换成国内镜像源 + npmmirror,12 分钟后 fefd366 又回滚了,理由写在 message 里:生产机的 daemon 代理已经修好了,而且那个镜像源里根本没有 oven/bun 的镜像。搞什么飞机,绕了一圈只有 npmmirror 那一半留了下来,就是 Dockerfile:16 那行 ENV NPM_CONFIG_REGISTRY。换基础设施之前先确认对面有没有你要的那个镜像啦…

compose 这边收敛得比较狠。db 服务完全不映射端口,数据导入维护全走 docker compose exec -T db psql/pg_restore 的 stdin 管道:

db:
  volumes:
    # postgres:18 官方镜像的卷挂载点改为 /var/lib/postgresql(内含 18/docker 数据目录)
    - pgdata:/var/lib/postgresql
  # 不映射任何端口:整机只暴露 app 的 1270 给 Cloudflare Tunnel

对外的口子只有 app 服务这一个,还绑死在回环上:

app:
  ports:
    # 只绑本机回环,公网入口走 Cloudflare Tunnel
    - "127.0.0.1:1270:3000"

postgres:18 那个卷挂载点是新变的,照老版本写 /var/lib/postgresql/data 会踩。顺手吐个槽:0b7117c 移除了 db 的端口映射之后,deploy/README.md 里第 43、118、122、126 行还残留着改之前的端口号,配置和文档已经对不上了,这种不一致 AI 是不会主动帮你同步的…

不用 Docker 的两种

不是每个项目都值得上 compose。acgn-flow 用 PM2,配置就一份 ecosystem.config.cjs

instances: 1,
exec_mode: "fork",          // 单实例 fork,不用 cluster
max_memory_restart: "2G",
exp_backoff_restart_delay: 500,
max_restarts: 15,
min_uptime: "30s",
kill_timeout: 5000,

部署脚本就一行:pnpm build && pm2 reload ecosystem.config.cjs --update-env。为什么是 fork 不是 cluster,仓库里其实没写理由,我当时是这么想的:Next 自己已经是单进程多路复用了,再套一层 cluster 收益不大,内存倒是先翻倍——不过这只是我的判断,没实测过喽。

thdl 走的是 systemd,而且没有部署脚本,thdl/README.md:64-66 里就是三条手打的 rsync(.next/standalone/.next/staticpublic),每次靠手敲。unit 里加了几条加固:

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/www/thdl /var/log/thdl

这三种里 Dockerfile 是最容易出岔子的,多阶段构建里哪些东西在哪一层存在,模型经常搞混——上面那个 bun 镜像没有 node 就是典型。PM2 那份倒是一次就对,毕竟字段少、语义直白,想写错都难。

Service Worker 是自托管里最容易翻车的一块

这块单独拎出来说,因为它的问题从来不在本地出现。

项目场景问题解法
bbps@serwist/next 在纯静态导出下没跑通(原因 commit 里没写)c2e3cf48 引入,隔了一个提交、1 个多小时后 dd40bcf5 全删,改成 build 之后跑独立脚本
bbps预缓存 glob 把托管平台的 _worker.js 也吞了ea65e4b0 只改 1 行:globIgnores 里加 '_worker.js'
acgn-flow官方插件和打包器兼容性问题21e3e75 改用 serwist CLI 替代 @serwist/next
mikiacg某些嵌入式 WebView 缓存过期页面导致白屏跳过 SW 注册,并主动注销已装的旧 SW
home删掉 PWA 之后老访客还带着旧 SW留一个墓碑 SW 让它自己注销自己(完整实现我在讲迁移那篇里贴过)

表格第一行得补一句:dd40bcf5 的 commit message 只写了「removing ‘@serwist/next’ integration」,没给原因,README 和配置里也都没留说明。这项目确实是 output: 'export'next.config.ts:4),所以我猜是这俩不兼容,但这是我的推断,不是仓库里能验证的事——对比 acgn-flow 那行就有 21e3e75 的「解决 Turbopack 兼容性问题」白纸黑字写着,bbps 这行没有。

bbps 那个改造之后 build 变成了三段串联:next build && node scripts/build-sw.mjs && node scripts/generate-feed.mjs。中间那个脚本自己先用 esbuild 把 src/app/sw.ts 打成一个 bundle,再用 injectManifestout/ 注入预缓存清单,最后 unlinkSync 把中间产物删掉。顺序不能反,因为它 glob 的就是 out/

mikiacg 那条最典型,注释就写在 src/components/providers.tsx:62-65:那个 WebView 的缓存策略跟浏览器不一致,容易缓存过期页面导致白屏;而且必须主动注销,不然用户先从网页版打开过、旧 SW 已经装上了,再进 WebView 照样中招。同一个文件 :36-55 还配了 isChunkLoadError() + safeReload(),10 秒节流、sessionStorage 去重,专门救发版之后老页面加载不到新 chunk 那种情况。

SW 的毛病都不会在本地出现,只会在有老访客的线上出现…

静态资源和 feed 的搬运活

bbps 的 RSS 不由框架生成,是构建完之后一个独立 Node 脚本把 feed.xml 写进 out/。副作用是 .mjs 脚本 import 不了 TS 模块,所以 frontmatter 解析在 scripts/generate-feed.mjssrc/lib/blog.tsx:112-123 各实现了一遍,逻辑几乎逐行相同——知道重复也没什么好办法,除非再上一层构建。

另一个团队项目也有类似的活:standalone 构建完还得手工把 public.next/static 拷进 standalone 目录,而且这段逻辑在 postbuild 和部署脚本里写了两遍。两边哪天不同步了,表现就是线上样式全丢,但 build 一路绿灯。

还有一个更隐蔽的:那个项目运行时上传的文件一开始是写进 public 下的,而 standalone 每次重建都会把它清掉,用户传的东西过一次发版就没了。最后是挪到独立目录,再加一个带路径穿越防护的读取路由。同一次修复还顺带发现,校验规则里的 URL 检查拒收站内相对路径——上传成功了但存不进库,两个坑叠在一起,查了半天。

写这篇的时候顺手把上面提到的行号和 commit 都重新对了一遍,结果发现自己原来记的「四个坑」里有三个是冤枉 AI 的,只好删掉重写了一节

溜了溜了~