Official docs referenced in this post: Next.js 自托管, Serwist, PM2 配置项, postgres 镜像说明
I’ve been self-hosting three or four Next.js projects back to back lately, so here’s a record of a few pitfalls I hit. Claude writes features amazingly fast — pages, tRPC routes, Prisma schemas all churned out in one go and they just run. But the moment deployment and wrap-up come around, it’s back and forth, the same file edited over and over. Everything below is the “build passes but it won’t run” category, with zero warning signs locally…
Enough chatter — let’s get to it!
The standalone multi-stage image: only one of these was a real pitfall
I originally wanted to title this “four pitfalls,” but the git log slapped me in the face~ Over its entire lifetime, the Dockerfile in vns-next has only been touched by 4 commits: 404a769 created it, then 0b7117c, 037ccf4, and fefd366 each changed it once. And three of the items below were already written correctly in the very first commit — I never actually “hit” them.
First, the one I actually hit. The build base in 404a769 was originally a plain oven/bun image, and it died outright, so 0b7117c added 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 /usr/local/bin/bun /usr/local/bin/bunThe oven/bun:1 image has no node at all, so prisma generate dies the moment it runs — but the error only says the CLI can’t find an interpreter, and without looking at the shebang you’d never guess why. The fix: use node:24-bookworm-slim as the base and just COPY a single binary out of the bun image as the package manager.
The other three were already in place in the first commit. Here are a few key excerpts (the original file has no numbering; I’ve split them up for posting):
# 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 /app/src/generated ./src/generatedI think the NEXT_PUBLIC_* one is the easiest to overlook: it’s not a runtime variable, it gets inlined as a literal into the client bundle at the moment of next build. I deliberately wrote it out again in deploy/README.md:153-154 — change a variable like this and you must --build and rebuild the image; editing .env and restarting the container does nothing.
The second real pitfall came at b07e20d: there’s no database container inside the builder layer, and Next by default statically prerenders DB-querying pages during next build, so it’s guaranteed to throw. That one belongs to the rendering mode rather than the image itself, and I covered both the fix and how to verify it in another post; here I’ll just note what it means for the Dockerfile — it’s the only pitfall where “not a single word of the Dockerfile is wrong but the build still won’t pass,” and you can stare at the multi-stage build all day without finding a problem.
On the compose side: two round trips on registries, ports narrowing all the way down
The registry thing is pretty funny. 037ccf4 swapped all the base images to a domestic mirror + npmmirror, and 12 minutes later fefd366 rolled it back, with the reason in the message: the daemon proxy on the production machine had been fixed, and that mirror didn’t have a oven/bun image anyway. What a mess — after all that circling, only the npmmirror half survived, i.e. the ENV NPM_CONFIG_REGISTRY line in Dockerfile:16. Before you swap out infrastructure, check that the other side actually has the image you want…
The compose side converged pretty aggressively. The db service maps no ports at all; data imports and maintenance all go through docker compose exec -T db psql/pg_restore's stdin pipe:
db:
volumes:
# postgres:18 官方镜像的卷挂载点改为 /var/lib/postgresql(内含 18/docker 数据目录)
- pgdata:/var/lib/postgresql
# 不映射任何端口:整机只暴露 app 的 1270 给 Cloudflare TunnelThe only outward-facing opening is the app service, and it’s pinned to the loopback:
app:
ports:
# 只绑本机回环,公网入口走 Cloudflare Tunnel
- "127.0.0.1:1270:3000"The volume mount point for postgres:18 changed recently; if you copy it from an older version, your /var/lib/postgresql/data will break. And while I’m complaining: after 0b7117c removed the db port mapping, lines 43, 118, 122, and 126 of deploy/README.md still have the old port numbers — the config and docs no longer match, and AI won’t proactively sync that kind of inconsistency for you…
The two that don’t use Docker
Not every project is worth bringing compose to. acgn-flow uses PM2, with a single ecosystem.config.cjs config:
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,The deploy script is a single line: pnpm build && pm2 reload ecosystem.config.cjs --update-env. Why fork instead of cluster isn’t actually explained in the repo; here’s what I was thinking at the time: Next already does single-process multiplexing, so wrapping another layer of cluster brings little benefit and doubles memory first — but that’s just my judgment, I never actually benchmarked it.
thdl goes the systemd route and has no deploy script — thdl/README.md:64-66 is just three hand-typed rsync commands (.next/standalone/, .next/static, public), retyped every time. A few hardening directives were added to the unit:
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/www/thdl /var/log/thdlOf the three, the Dockerfile is the most error-prone: models often mix up what exists in which layer of a multi-stage build — the bun image having no node above is a textbook case. The PM2 one was right on the first try; after all, it has few fields and plain semantics, so it’s hard to get wrong even if you tried.
Service Workers are the most crash-prone part of self-hosting
I’m giving this its own section because the problems never show up locally.
(This is part 1 of 2 of the same post.)
| Project | Problem | Solution |
|---|---|---|
| bbps | @serwist/next never worked under pure static export (the reason isn’t in the commit) | c2e3cf48 was introduced, then a commit and over an hour later dd40bcf5 was removed entirely, replaced by a standalone script that runs after the build |
| bbps | The precache glob also swallowed the hosting platform’s _worker.js | ea65e4b0 changed just 1 line: add '_worker.js' to globIgnores |
| acgn-flow | Compatibility issues between the official plugin and the bundler | 21e3e75 switched to the serwist CLI instead of @serwist/next |
| acg-site | Some embedded WebViews cache stale pages, causing a blank screen | Skip SW registration and actively unregister any old SW already installed |
| home | After the PWA was removed, returning visitors still had the old SW | Leave a tombstone SW that unregisters itself (I posted the full implementation in the migration article) |
One thing to add about the first row: the commit message for dd40bcf5 only says “removing ‘@serwist/next’ integration”, with no reason given, and neither the README nor the config leaves any explanation. The project is indeed output: 'export' (next.config.ts:4), so I’d guess the two are incompatible, but that’s my inference, not something you can verify in the repo — compare that to the acgn-flow row, where 21e3e75's “fix Turbopack compatibility issue” is written in black and white. The bbps row has nothing.
After the bbps rework, the build became a three-stage chain: next build && node scripts/build-sw.mjs && node scripts/generate-feed.mjs. The middle script first uses esbuild to bundle src/app/sw.ts into a single bundle, then uses injectManifest to scan out/ and inject the precache manifest, and finally unlinkSync deletes the intermediate artifacts. The order can’t be reversed, because out/ is exactly what it globs.
The acg-site one is the most typical; the comment sits right there in src/components/providers.tsx:62-65: that WebView’s caching policy doesn’t match the browser’s, so it easily caches stale pages and causes a blank screen; and you have to unregister proactively, otherwise a user who opened the web version first already has the old SW installed, and going into the WebView still hits the same problem. In the same file, :36-55 also wires up isChunkLoadError() + safeReload(), with a 10-second throttle and sessionStorage deduplication, specifically to rescue the case where old pages can’t load new chunks after a release.
SW bugs never show up locally; they only show up in production, where returning visitors exist…
The grunt work of moving static assets and the feed around
bbps's RSS isn’t generated by the framework; a standalone Node script writes feed.xml into out/ after the build finishes. The side effect is that the .mjs script can’t import TS modules, so frontmatter parsing is implemented twice, once in scripts/generate-feed.mjs and once in src/lib/blog.tsx:112-123, with nearly line-for-line identical logic — knowing it’s duplicated doesn’t buy you much unless you add another layer of build on top.
Another team project has similar grunt work: after a standalone build you still have to manually copy public and .next/static into the standalone directory, and that logic is written twice — in postbuild and in the deploy script. If the two ever fall out of sync, the symptom is that all styling disappears in production while the build stays green the whole way.
There’s a more insidious one too: files uploaded at runtime by that project were initially written under public, and every standalone rebuild wipes it out, so anything users uploaded was gone after a single release. In the end we moved it to a separate directory and added a read route with path-traversal protection. The same fix also turned up that the URL check in the validation rules rejected site-relative paths — the upload succeeded but couldn’t be saved to the database. Two bugs stacked together took ages to track down.
While writing this I went back and re-checked all the line numbers and commits mentioned above, and it turned out three of the “four pitfalls” I’d originally written down were me wrongly blaming the AI, so I had to delete that section and rewrite it.
Anyway, I’m outta here~
