惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

有赞技术团队
有赞技术团队
Martin Fowler
Martin Fowler
N
Netflix TechBlog - Medium
WordPress大学
WordPress大学
罗磊的独立博客
H
Help Net Security
MongoDB | Blog
MongoDB | Blog
A
About on SuperTechFans
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
D
Docker
云风的 BLOG
云风的 BLOG
Microsoft Security Blog
Microsoft Security Blog
Blog — PlanetScale
Blog — PlanetScale
P
Proofpoint News Feed
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
I
InfoQ
J
Java Code Geeks
博客园 - 聂微东
大猫的无限游戏
大猫的无限游戏
Engineering at Meta
Engineering at Meta
美团技术团队
小众软件
小众软件
Stack Overflow Blog
Stack Overflow Blog
C
Check Point Blog

定の栈

多个 AI 一起写代码时,我给它们划的边界 9 个 commit,没有一个人类作者:AI 独自写完一个项目之后 Next.js 渲染模式排查笔记:force-dynamic、hydration 与构建期没有数据库 让 Claude 少写错数据层:Prisma、Drizzle 和几条结构化做法 我的 CLAUDE.md 只有一行:约定文件到底该写多少 同一个月,把主页搬出 Next.js,又把 Hugo 站搬进来 macOS Tahoe 如何用自己的视频做动态壁纸(替换 Aerial 方法) Podman Compose 常用命令速查 创建 Swap 文件 Cursor 2025 Hugo 中文阅读时间计算模版 手动重加载不蒜子计数 入坑舞萌 DX 历时两月终抵 w0 与首鸟加 多邻国 600 天连胜 原神五周年纪念 Asuna 成年生日 告别绝区零 站长18周岁啦! 一位动漫迷的追番日志与热情之旅 起飞日志 Reflector 镜像列表更新常用命令与配置文件 个人自用 rsync 文件同步常用命令 历时千日原神,深渊终抵满星 Arch Linux 个人常用命令记录 《三体III:死神永生》 《三体II:黑暗森林》 《千恋*万花》与现代物理学概念奇妙碰撞后产生出的糟糕想法 Minecraft 15 周年骨折价补票入正 网页添加 Live2D 看板娘 记第二次清醒控梦体验
AI 写得出功能,写不好 Dockerfile:Next.js 自托管踩坑记录
Asuna · 2026-07-21 · via 定の栈

最近连着给三四个 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 的,只好删掉重写了一节

溜了溜了~