9月29日から30日までの2日間、21回のコミットで nav-site を Vercel + Neon からシンガポールの VPS へ移行した。Cloudflare Tunnel で入り、オリジンサーバーはパブリックポートを一切開けず、postgres は 127.0.0.1:5432 のみにバインドし、アプリは Docker Compose で動かし、ビルドもサーバー上で専用の buildx ビルダーを使って行う。

移行自体は最終的に成功した。しかし、それまでのコミットはほぼすべて同じ類のものを補うものだった。これまでプラットフォームが代わりにやってくれていた前提である。この記事は発見順に記し、コミット順には沿わない。

一度のジャンプで 0.0.0.0:3000 に飛ぶ

本番切り替え前にプレビューで実測すると、/r/<渠道码> の 302 のリダイレクト先は https://0.0.0.0:3000/ で、Google ログイン成功時もエラー時の戻り先も同じだった。公開直前に初めて気づくようなことがあると、サイト全体の短縮リンクが一斉に無効になる。

根本原因は next-server.js の attachRequestMeta にある。standalone モードでは next-server がリッスンしている hostname/port を保持しており、リクエスト URL を構築する際にそれらを優先するため、experimental.trustHostHeader の分岐には到達しない。Vercel ではプラットフォームが自動的に Host を信頼する(config.js が hasNextSupport のときに有効になる)ので、移行前は一度も露見しなかった。実測では Host も X-Forwarded-Host も req.url に影響せず、プロトコルだけが X-Forwarded-Proto に従う。

修正は lib/public-origin.ts を新規追加すること。Host ヘッダーを取得し(オリジンはトンネル経由でしか到達できず、Host は Cloudflare が訪問者のリクエストしたドメインに基づいて書き込む)、プロトコルは https の X-Forwarded-Proto のみを認める。X-Forwarded-Host は意図的に信頼しない——訪問者が自分で付けて送れてしまい、Cloudflare がそのまま透過するため、信じるとオープンリダイレクトになる。

ついでに openapi.json と llms.txt も差し替えた。これらは元々 XFH を優先して読んでサイトルートを組み立てていたが、セルフホスト後は偽装可能であり、しかも API がエッジで1時間キャッシュされるため、キャッシュポイズニングに等しい。

同じコミットで、prod と preview が一つのイメージタグを取り合う

どちらも nav-site:<sha> をビルドする。しかし同じコミットでも両者の成果物は同一ではない。ビルド時に接続する DB、プリレンダーデータ、NEXT_PUBLIC_* が異なる可能性がある。後からビルドした方がタグを奪ってしまう——実測では本番が後からビルドすると、preview コンテナのイメージが名もなき sha256:… に変わってしまった。逆に、本番の再起動やロールバックが preview のデータを含むビルドで動いてしまう。

nav-site:<env>-<sha> に変更し、ビルド、公開、ロールバック、クリーンアップのすべてに環境プレフィックスを付けた。イメージの同一性はコミットだけに頼ってはならない。成果物が異なるものは同じ名前を共有すべきではない。

secret がキャッシュキーに入らない

VPS 上で CI 経由の初回公開を行った際、2つのコミット間で変わったのは deploy/ スクリプトだけ(ビルドコンテキストには入らない)で、チェーン全体がキャッシュにヒットし、5秒で完了した。キャッシュ自体に問題はないが、より静かな落とし穴が露呈した。ビルド時の環境変数は BuildKit secret 経由で注入され、secret の内容はキャッシュキーに含まれない。env だけ変更しコードが変わっていない場合、pnpm build は古いキャッシュにヒットし、新しい NEXT_PUBLIC_* が静かに効かなくなる。

修正は deploy.sh でビルド用 env ファイルの sha256 の先頭16ビットを build arg として渡し、Dockerfile で echo すること。env が変わればキャッシュは即座に無効になる。

EXIT trap がローカル変数を参照し、成功した公開が終了コード 1 になる

cmd_build_deploy / cmd_migrate の EXIT trap が関数のローカル変数を使っていた。関数が正常に戻った後にスクリプトが終了する際に trap が発火し、set -u では「未バインド」と報告され、成功した公開が終了コード 1 になってしまう——CI は毎回の成功を赤くマークする。クリーンアップ完了後に trap - EXIT で取り除いた。

pg-verify が「空対空」を一致と判定する

初めて Neon に対してインポートを実行したとき、ソース DB は証明書パラメータのせいで接続に失敗した。しかし pg-verify は「✓ シーケンス値一致」と出力した——両方とも空の結果で、テーブルループも空で ✗ は一つもなかった。スクリプトは最終的に set -e が非ゼロ終了したため終了したものの、明確な結論は何もなく、切り替え当晚には通過と読まれかねない。

現在はまず両端とも接続できること、ソース DB の public に確かにテーブルがあることを確認し、そうでなければ即座に exit 2 する。あわせて覚え書き:libpq で sslmode=verify-full を使うには必ず sslrootcert=system を加える必要がある。Node の pg ドライバはこのパラメータを認識しないため、アプリ用の文字列には sslmode=verify-full のみを書く。

公開バケットの中のバックアップ

元々は新たにプライベートなバックアップバケットを作ることを提案していたが、後にメインバケット nav-bucket をそのまま使うことに決めた——公開 CDN ドメインが付いており、公開で読み取り可能で、オブジェクトキーもタイムスタンプ付きで推測可能だ。DB にはユーザーのメールアドレス、パスワードハッシュ、提携先の鍵があり、平文で置くのは公開するのと同じである。

そこでアップロード前に一律 age で暗号化し、BACKUP_AGE_RECIPIENT が空の場合はアップロードを即座に拒否し(fail-closed)、暗号文はランダムなサフィックスのプレフィックスに置く。復号用の秘密鍵は /opt/nav-site/env/backup-age.key にあり、マシンの外にも必ず別途保存しておく必要がある。マシンが丸ごと失われたとき、R2 上のバックアップはそれでしか開けないのだから。

一つの鍵を三つに分ける

CHANNEL_REPORT_SECRET、USER_SESSION_SECRET、ADMIN_SESSION_SECRET、CRON_SECRET は以前どれも同じ値にフォールバックしていたため、外部公開リンクの有効性は、名前からは関連が見えない一本の鍵に縛られていた。移行時にこれを分離し、deploy.sh はいずれかが欠けていると公開を拒否する。

そのうち CHANNEL_REPORT_SECRET を一度でも差し替えると、取引先に配布済みの外部公開リンクと QR コードがすべて無効になるため、サーバーの外に別途保管しておかなければならない。ほかの何本かはいつでも差し替えられ、代償はログイン中の人が一度失敗するだけだ。いつでも差し替えられる鍵と、そうでない鍵は、同じ見た目にしてはいけない。

ほかにも地味だが牙をむくものがいくつかある

  • このマシンの fail2ban は認証失敗 3 回で 24 時間ブロックする。接続できないときはまず原因を調べること。ユーザー名をあれこれ試さない。
  • sshd はポート転送を無効にしているので、移行もスクリプト実行も DB 確認もすべてサーバー上で行う。SSH トンネルという逃げ道はない。
  • 移行ツールには記録テーブルがなく、毎回ファイル名ごとに全件を再実行する。移行ファイルは冪等でなければならない。
  • 公開中は通常およそ 7 秒、リクエストの失敗はゼロで、起動できない不良バージョンは自動的に撤去される。ただし Docker のサービス名解決はヘルスステータスを見ないため、ポートをリッスンできても誤った応答を返すバージョンには、最長 30 秒のヘルスチェック期間中におよそ半分のトラフィックが振り分けられる。この境界はマニュアルに書き記してあり、存在しないふりはしていない。
  • バックアップは四半期に一度以上、リストアを演習する——一度も復元したことのないバックアップはバックアップとは言えない。

移行後のデフォルト値

Vercel は Host を信頼し、ビルドごとに独立したアイデンティティを与え、バックアップバケットがプライベートであることを保証し、結果が空のときには「一致」と言ってくれた。セルフホストとは、それらをなくすことではなく、プラットフォームのデフォルト値から自分のデフォルト値へと変えることだ。ひとつの publicOrigin、ひとつの <env>-<sha> タグ、ひとつの sha256 build arg、ふたつの事前チェック、ひとつの age 公開鍵。

移行チェックリストで本当に重要なのは、決してコマンドではなく、こうした前提が一つひとつ受け止められているかどうかだ。

(これは同じ記事の後編 2/2 です。)