この記事で参照している公式ドキュメント:Next.js 自托管、Serwist、PM2 配置项、postgres 镜像说明

最近続けて三、四件の Next.js プロジェクトをセルフホストしたので、そこで踏んだ穴をいくつか記録しておくね。機能の部分は Claude が書くのが本当に速くて、ページも tRPC ルートも Prisma スキーマも一気に作れて、そのまま動くんだ。でもデプロイの仕上げになると、同じファイルを何度も書き換えては戻してを繰り返す。以下は全部「build は通るのに動かない」系の話で、ローカルではまったく兆候すらなかったやつら…

前置きはいいから、本題いこう!

standalone マルチステージイメージ:実際に穴を踏んだのは一つだけ

本当はタイトルを「四つの穴」にしようと思ったんだけど、git の履歴を見たら顔を叩かれた~ vns-next の Dockerfile はライフサイクル全体で 4 回のコミットでしか触られていない:404a769 で作成、その後 0b7117c、037ccf4、fefd366 でそれぞれ一回ずつ修正。で、以下の項目のうち三つは最初のコミットの時点ですでに正しく書けていたので、そもそも「踏んで」いないんだ。

まずは本当に踏んだやつから。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 段階で DB を参照するページを静的プリレンダリングしてしまうので、必ずエラーになる。この穴はイメージそのものではなくレンダリングモードに属する話で、別の記事で修正方法と検証手段をまとめて詳しく書いた;ここでは 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。なぜ cluster ではなく fork なのか、リポジトリには実は理由が書いていない。当時の僕の考えはこうだった:Next 自体がすでにシングルプロセスで多重化しているので、さらに cluster を重ねてもあまり恩恵はなく、むしろメモリが先に倍になる——ただこれは僕の判断であって、実測はしていないけどね。

thdl は systemd で動かしていて、しかもデプロイスクリプトはない。thdl/README.md:64-66 にあるのは手打ちの rsync 三本(.next/standalone/、.next/static、public)で、毎回手で叩いている。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 コミット・1 時間余りを挟んで dd40bcf5 を全削除し、build 後に独立スクリプトを走らせる方式に変更
bbpsプリキャッシュの glob がホスティングプラットフォームの _worker.js まで巻き込んでいたea65e4b0 を 1 行だけ修正:globIgnores に '_worker.js' を追加
acgn-flow公式プラグインとバンドラーの互換性問題21e3e75 を serwist CLI に置き換え、@serwist/next を廃止
acg-site一部の組み込み WebView でキャッシュが古いページを返して白画面になるSW の登録をスキップし、インストール済みの古い SW を能動的に解除する
homePWA を削除した後も古い訪問者に古い SW が残っていた墓標 SW を置いて自分自身を解除させる(完全な実装は移行の記事に貼った)

表の 1 行目には一言補足が必要だ:dd40bcf5 の commit message には「removing ‘@serwist/next’ integration」とだけ書かれていて理由がなく、README にも設定にも説明は残っていない。このプロジェクトは確かに output: 'export'(next.config.ts:4)なので、この 2 つが非互換なのだろうと推測しているが、あくまで推測であってリポジトリで検証できる話ではない。acgn-flow の行と比べれば 21e3e75 の「解決 Turbopack 兼容性问题」がはっきり書かれているのに対し、bbps の行には何もない。

bbps の改修後、build は三段の直列処理になった:next build && node scripts/build-sw.mjs && node scripts/generate-feed.mjs。真ん中のスクリプトはまず esbuild で src/app/sw.ts を 1 つのバンドルにまとめ、次に injectManifest で out/ を走査してプリキャッシュ一覧を注入し、最後に unlinkSync で中間生成物を削除する。順序は入れ替えられない。glob の対象が out/ だからだ。

acg-site の項目が最も典型的で、コメントは src/components/providers.tsx:62-65 に書いてある:その WebView のキャッシュ戦略はブラウザと一致せず、古いページをキャッシュして白画面になりやすい。しかも能動的に解除する必要がある。そうしないと、ユーザーが先に Web 版を開いて古い SW が入ってしまった後、WebView に入っても同じ目に遭うからだ。同じファイルの :36-55 には isChunkLoadError() + safeReload() も設定されており、10 秒のスロットリング、sessionStorage による重複排除で、リリース後に古いページが新しい chunk を読み込めない状況を狙い撃ちで救う。

SW の不具合はローカルでは決して再現せず、古い訪問者がいる本番でだけ現れる…

静的リソースと feed の運搬作業

bbps の RSS はフレームワークが生成するのではなく、ビルド後に独立した Node スクリプトが feed.xml を out/ に書き込む。副作用として、.mjs スクリプトは TS モジュールを import できないため、frontmatter のパースが scripts/generate-feed.mjs と src/lib/blog.tsx:112-123 でそれぞれ実装され、ロジックはほぼ行単位で同一になっている。重複だと分かっていても良い方法はなく、もう一段 build を挟むしかない。

別のチームプロジェクトにも似た作業がある:standalone ビルドの後、手動で public と .next/static を standalone ディレクトリにコピーする必要があり、しかもこのロジックは postbuild とデプロイスクリプトの 2 か所に書かれている。どちらかが同期しなくなると、症状は本番でスタイルが全消失、それなのに build は最後までグリーンだ。

さらに見つけにくいものもある:そのプロジェクトで実行時にアップロードされたファイルは最初 public 配下に書き込まれていたが、standalone は再ビルドのたびにそれを消してしまうため、ユーザーがアップロードしたものはリリースを一度またぐと消えていた。最終的には独立したディレクトリに移し、パストラバーサル対策付きの読み取りルートを追加した。同じ修正でついでに分かったのは、バリデーションルールの URL チェックがサイト内の相対パスを拒否していたこと——アップロードは成功するのに DB に保存されず、2 つの罠が重なっていて半日調べ回ることになった。

この記事を書くにあたって、上で挙げた行番号と commit をすべて照合し直したところ、自分が最初に数えた「4 つの罠」のうち 3 つは AI の冤罪だったので、その節は削除して書き直す羽目になった

撤収撤収〜