Newer
Older
client_dwatch / docs / deploy.md

本番デプロイ手順

開発側のセットアップは docs/getting-started.md にあります。この文書は本番側です。 サーバは さくらのクラウド VM(Linux)、DB は さくらインターネットのデータベース アプライアンス(MariaDB 10.11)(CLAUDE.md §3 / D-100)。

★出す直前に潰すチェックリストは「7. 本番へ出す前のセキュリティ確認」にあります。★


1. 環境変数の一覧

★コードが実際に読んでいる環境変数は5つだけです。★ (DATABASE_URL / LOG_LEVEL / NODE_ENV / USE_FIXTURES の4つに、 指示書I で AUTH_TOKEN が5つ目として加わりました)

キー 本番で入れる値 未設定だとどうなるか 読んでいる場所
USE_FIXTURES ★false★ ★true になる★(!== "false" のため)。実DBに繋がらずサンプルデータのまま動く。例外もエラーも出ない lib/db/use-fixtures.ts
DATABASE_URL 参照用アカウントの接続URL 画面が全部エラーになる lib/db/prisma.ts
AUTH_TOKEN ★必ず変更した共有トークン★ 誰もログインできない(=全機能が使えない) lib/auth/token.ts
LOG_LEVEL info NODE_ENV から判定(本番は info) lib/logger/index.ts
NODE_ENV production(npm run start が設定) ログレベルと禁止フィールドの扱いが開発モードになる lib/logger/index.ts
★ALLOW_DIRECT_ACCESS★ ★設定しない★ 未設定 = false = 拒否(★これが正しい状態★) lib/config/allowed-ips.ts

DATABASE_URL のパスワードに % や @ が含まれる場合は ★URLエンコードが必須★です (% はパーセントエンコードの開始記号、@ はホスト区切りとして解釈されるため)。

1-1. ★値の渡し方 — .env.local だけではありません★

アプリ(Next.js)も npm run check:env も prisma7.config.ts も、同じ順で読みます (Next.js の @next/env。先に見つかった値が勝つ):

★実際の環境変数(process.env)★ > .env.$(NODE_ENV).local > .env.local > .env.$(NODE_ENV) > .env

  • ★Docker では env_file(例 app.env)/ environment で渡してください★。process.env なので、どのファイルよりも優先されます。エラー文の「環境変数(Docker の env_file・.env・.env.local など)」はこのことです
  • ★イメージに .env.local を入れないこと★。コンテナ内にあると、env_file で渡していないキーについてはその値が読まれてしまいます(.dockerignore で除外する)
  • ★compose プロジェクト直下の .env(COMPOSE_PROJECT_NAME など)はアプリには渡りません★。compose が ${...} の展開に使うだけで、env_file / environment で参照したキーだけがコンテナに入ります

2. セットアップの手順

  1. .env.production.example をコピーして .env.local を作る (★Docker で動かす場合は .env.local ではなく env_file(例 app.env)に同じ値を書く★。§1-1)

    cp .env.production.example .env.local
  2. 各値を埋める。★AUTH_TOKEN は必ず変更する★ (changeme のままだと手順3で落ちます)

  3. ★npm run check:env を実行して通ることを確認する★

    npm run check:env

    ★エラーが出たら先に進まないでください。★ 終了コードが非0になります。 警告だけなら 0 なので、内容を読んで意図どおりか判断してください。 (検査の中身は lib/config/env-check.ts。値そのものは出力しません — DATABASE_URL にパスワードが入るため)

  4. npm run build

  5. npm run start


3. ★ALLOW_DIRECT_ACCESS は本番で設定しないこと★

★設定すると、nginx を通らない直接アクセスが素通りします。★

  • 未設定 = false = 拒否が正しい状態です
  • 実装は process.env.ALLOW_DIRECT_ACCESS === "true" なので、 ★未設定・空文字・タイポ・1・TRUE はすべて false(拒否)★になります。 設定を忘れたら閉じる方向に倒れます
  • ★既存の USE_FIXTURES は !== "false"(未設定なら true)ですが、向きが逆なのは意図的です。★ セキュリティに関わる設定は「未設定を閉じる側に倒す」のが規則です。 ★「既存に合わせて統一しよう」として戻さないでください★(D-101)
  • 手順3 の npm run check:env が、本番判定でこれをエラーとして検出します(検査2)

4. nginx 側の前提

4-1. ★X-Forwarded-For に「追記」すること★(D-077 項5)

$proxy_add_x_forwarded_for 相当にしてください。

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

★上書き($remote_addr を単に代入)にすると TRUSTED_PROXY_HOPS の前提が崩れます。★

アプリは X-Forwarded-For の ★末尾から TRUSTED_PROXY_HOPS 番目★ を接続元とみなします (TRUSTED_PROXY_HOPS = 1 = 末尾。LB と nginx は同一なのでプロキシは1段。D-079 項1)。 ★先頭は使いません。★ 先頭はクライアントが自由に詐称できるためです。

4-2. ★アプリのポートに nginx 以外が到達できないようにすること★

★これはネットワーク側の設定であり、アプリでは防げません。★

ALLOW_DIRECT_ACCESS はあくまで「XFF が取れないとき」の保険であって、 ★偽装 XFF は防げません★。直接アクセスを本当に塞ぐのはネットワーク側です。

★2026-09-10 時点で「アプリのポートが nginx からのみ到達可能か」は未確認のままです。★ デプロイ時に必ず確認してください(docs/decisions.md D-101 の申し送り)。

4-3. ★★実測で分かった注意点 — next start は XFF を自分で埋める★★

★npm run start(Next.js の Node サーバ)は、X-Forwarded-For が無いリクエストに対して 自分で socket の接続元を1個入れます★(追記はしません。既にあれば触りません)。 段階5-2 の実測結果:

送った X-Forwarded-For アプリが見る要素数 clientIp(hops=1)
(送らない) 1 ★::ffff:127.0.0.1(socket の接続元)★
203.0.113.10 1 203.0.113.10
1.2.3.4, 203.0.113.10 2 203.0.113.10
ヘッダ行を2本 2 203.0.113.10

★帰結(重要)★:

  • ★clientIp() は実サーバ上で null を返しません。★ したがって ★ALLOW_DIRECT_ACCESS は実質的に発火しません★
  • ★直接アクセスを止めているのは ALLOWED_IPS + ALLOW_ALL_WHEN_EMPTY=false です★。 ALLOW_DIRECT_ACCESS ではありません
  • ★★したがって本番では ALLOW_ALL_WHEN_EMPTY を必ず false にし、 ALLOWED_IPS を埋めてください。★★ 空のままだと (ALLOW_ALL_WHEN_EMPTY の既定が true なので)誰でも通ります
  • IPv4射影表記(::ffff: 付き)で来ることが実測で確認できました。 ★ALLOWED_IPS には正規化前の生の表記をそのまま書いてください★(★5-1 参照★)

4-4. ★★セキュリティヘッダは nginx 側で付けてください★★(申し送り)

★アプリ側では付けていません。★ next.config.ts に headers() の設定はなく、X-Content-Type-Options / Referrer-Policy / X-Frame-Options / CSP のいずれも出していません (★意図的にそうしてあります。指示書AE 段階2-7 / D-126★)。

★アプリに入れなかった理由★:

  • ★P1 はDFA社内のみの利用で、IP制限(ALLOWED_IPS)が前段にあります★
  • ★★ヘッダは nginx で付けるほうが素直です★★ — ★アプリを再ビルド・再デプロイせずに直せます★。 ヘッダは運用しながら調整するものなので、変更にデプロイが要る場所に置きたくありません
  • ★CSP はアプリ側で入れると Next.js のインラインスクリプトで画面が壊れる可能性があります★

★★nginx で付けるときの推奨(P1)★★:

add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy same-origin always;
add_header X-Frame-Options DENY always;
  • ★★CSP は P1 では入れないでください★★ — ★入れるなら Next.js のインラインスクリプトを 通す設計(nonce 等)が先に要ります★。★中途半端に入れて画面が壊れたら、無理に通さず外すこと★
  • ★add_header は「同じ階層に1つでも add_header があると親階層のものを継承しない」★という nginx の性質があります。★location ブロック側にも書くなら、3行まとめて書いてください★

5. ★許可IPリストの変更にはデプロイが必要★

設定値 置き場所
ALLOWED_IPS lib/config/allowed-ips.ts
TRUSTED_PROXY_HOPS lib/config/allowed-ips.ts
ALLOW_ALL_WHEN_EMPTY lib/config/allowed-ips.ts

★この3つは環境変数ではありません。.env.local を直しても変わりません。★

コードで管理しているのは、★3つとも「本番で意味のある値」だから★です。 社内IPの列挙はレビュー対象にすべき情報なので、コードに置いて履歴を残します (ALLOW_DIRECT_ACCESS だけが「本番では常に false、true にするのは開発中だけ」という 性質なので、そちらだけ環境変数にしてあります)。

5-1. ★ALLOWED_IPS の書き方★

  • ★判定は文字列の完全一致のみ★です。 ★CIDR / レンジ / ワイルドカードは解釈されません★ (192.0.2.0/24 と書いても、その文字列と完全一致するIPは無いので常に拒否になります)
  • P1 は ★単一IPの列挙のみ★ と割り切っています。 拠点ネットワーク全体を許可したくなったら lib/config/allowed-ips.ts と isAllowedIp() を作り直すことになります
  • ★表記は正規化しません★(IPv6短縮形の展開 / IPv4射影 ::ffff: の展開 / ポート除去はしない)。 ★nginx が実際に付ける表記をそのままログで確認してから登録してください★ (auth.ip_denied のログに client_ip が出ます)
  • ★プライベートIP(10.x / 172.16-31.x / 192.168.x)を書かないこと。★ 万一プロキシが2段だった場合、末尾は LB の内部IPになります。 そこに一致してしまうと「全員素通り」という逆方向の事故になります (1段のままなら全員403で即座に気づけます)

6. トラブル時の切り分け — 403 が出たら

症状 疑うところ
★全員が 403★ ① ALLOWED_IPS(lib/config/allowed-ips.ts)に接続元が入っていない ② TRUSTED_PROXY_HOPS の段数違い(★2段構成なら末尾は LB の内部IPになる★) ③ ALLOW_ALL_WHEN_EMPTY=false かつ allowlist が空
★特定の人だけ 403★ ALLOWED_IPS の表記ゆれ(★IPv6短縮形 / IPv4射影 ::ffff: / ポート付き は正規化しないので、書き方が違うだけで拒否されます★)
★ローカルで 403★ .env.local に ALLOW_DIRECT_ACCESS=true が無い(★これが正しい既定の挙動です★)
★ログインできるが誰でも入れてしまう★ ★ALLOW_DIRECT_ACCESS=true が本番に残っている★(npm run check:env の検査2 が検出)/ ALLOWED_IPS にプライベートIPを書いてしまっている / ★ALLOW_ALL_WHEN_EMPTY=true のまま ALLOWED_IPS が空★(4-3 参照。これが最も起こりやすい)

★接続元の生の表記は auth.ip_denied のログで確認できます★(client_ip フィールド)。 拒否されたときだけ出ます。ログインの成否ログには接続元IPを載せていません(D-101)。

6-1. 段数を疑うときの判定表

実測した要素数 末尾の値 解釈 TRUSTED_PROXY_HOPS
1 社内IP(実際の接続元) 想定どおり — クライアントが XFF を送っておらず nginx が1個足した 1
2以上 社内IP(実際の接続元) 想定どおり — クライアントが自称 XFF を送り、nginx がその後ろに足した 1
2以上 ★社内IPでない値(10.x / 172.16-31.x / 192.168.x)★ ★2段構成を疑う★ — 末尾が LB の内部IPになっている ★2★

★見るのは要素数ではなく「末尾の値が社内IPかどうか」です。★ 要素数だけでは「nginx 経由の正常系」と「直接アクセス」を区別できません。

★2 に変えるのは設定値1行だけです。コード変更は不要★ (clientIp() は最初から「末尾から N 番目」で一般化してあります)。


7. ★★本番へ出す前のセキュリティ確認★★

★この節は「出す直前に上から順に潰すチェックリスト」です。★ (指示書AE 段階2-5。★1〜6節に散っていた確認事項をここに集約しました★)

★「実装済みだから確認不要」にしないでください。★ 認証・IP制限のコードはすべて実装済みです(lib/auth/token.ts の定数時間比較、 lib/auth/client-ip.ts の XFF 末尾参照、lib/auth/cookie.ts の httpOnly/secure/sameSite、 lib/logger/redact.ts の秘匿化)。★足りないのはコードではなく「出す直前の確認」です。★

7-1. チェックリスト(8項目)

# 確認すること どう確かめるか
1 ★AUTH_TOKEN が changeme のままでない★ npm run check:env(検査1)。★error で終了コードが非0になります★
2 ★ALLOW_DIRECT_ACCESS が本番で未設定★ npm run check:env(検査2)。★NODE_ENV=production のとき error★(開発中は warn)
3 ★USE_FIXTURES=false★ npm run check:env(検査3)。★これは warn 止まりです★ — ★終了コードは0のまま通るので、出力を目で読んでください★。未設定だと true 扱いで、★実DBに繋がらずサンプルデータのまま動きます(例外もエラーも出ません)★
4 ★ALLOW_ALL_WHEN_EMPTY が false、かつ ALLOWED_IPS が埋まっている★ ★lib/config/allowed-ips.ts を目視★。★★2026-09-13 時点の既定は ALLOW_ALL_WHEN_EMPTY = true / ALLOWED_IPS = [](空)★★ — ★このまま出すと誰でも通ります★(4-3 参照)
5 ★TRUSTED_PROXY_HOPS が実構成と合っている★ ★lib/config/allowed-ips.ts を目視★+★6-1 の判定表★。★LB と nginx が別段になったら 2 にする★
6 ★nginx が X-Forwarded-For を「追記」している(上書きでない)★ ★4-1 の手順★($proxy_add_x_forwarded_for)。★上書きにすると項目5の前提が崩れます★
7 ★リポジトリに認証情報が入っていない★ git grep で接続URL・パスワード相当の記述を探す。★2026-09-13 時点の実測では実在の値は0件★(pw / s3cret / example.com 等のダミーのみ)。★ただし 7-3 も読むこと★
8 ★npm audit の結果を読んだ★ ★7-4 の記録★。★直すかどうかは別判断です(勝手に major を上げないこと)★

7-2. ★★項目4・5 は .env.local を直しても変わりません★★

★ALLOWED_IPS / ALLOW_ALL_WHEN_EMPTY / TRUSTED_PROXY_HOPS の3つは 環境変数ではなくコードです★(5節 / lib/config/allowed-ips.ts)。 ★★変更にはデプロイが要ります★★。

★★ここを見落とすと ALLOW_ALL_WHEN_EMPTY = true のまま本番へ出ます★★。 ★そしてその状態は「動く」ので、画面を見ても気づけません★。 ★チェックリストに項目4・5が載っている意味はここにあります★。

7-3. ★★過去にDBユーザー名をコミットした事故があります★★

  • ★c832891(fix: テストから実在のDBユーザー名を除去する / 2026-09-10)で★除去済み★
  • ★★ただし Git の履歴には残っています★★(★コミットを消してはいません★)
  • ★★したがって「いま git grep して出ない」=「漏れていない」ではありません★★。 ★項目7 が確かめているのは「これ以上増えていないこと」だけです★
  • ★★本番の資格情報は、履歴に入った値と別のものを発行してください★★ (→ docs/handoff.md の申し送り)

7-4. ★npm audit の記録★

★この節には、実行した日付と結果をそのまま残します★。 ★★直すかどうかは別判断です★★ — ★npm audit fix --force は使わないこと★ (prisma のダウングレードを含む破壊的変更が走ります)。

2026-09-13 の実測

# npm audit report

deepmerge-ts  <8.0.0
Severity: high
DeepmergeTS has stack exhaustion when merging recursive object graphs - https://github.com/advisories/GHSA-ggr8-5vv4-36mx
fix available via `npm audit fix --force`
Will install prisma@6.19.3, which is a breaking change
node_modules/deepmerge-ts
  @prisma/config  6.13.0-dev.1 - 8.1.0-dev.4
  Depends on vulnerable versions of deepmerge-ts
  node_modules/@prisma/config
    prisma  >=6.13.0-dev.1
    Depends on vulnerable versions of @prisma/config
    Depends on vulnerable versions of mysql2
    node_modules/prisma

js-yaml  4.0.0 - 4.3.1
Severity: high
js-yaml: maxTotalMergeKeys does not limit CPU use for empty merge sources - https://github.com/advisories/GHSA-2883-xcg3-v3hh
fix available via `npm audit fix`
node_modules/js-yaml

mariadb  3.4.0 - 3.4.5
Severity: high
MariaDB's connector leaks the cleartext password to an MitM despite `ssl: true` - https://github.com/advisories/GHSA-cqhc-2h57-wpxf
MariaDB has Cleartext Transmission of Sensitive Information and Insufficiently Protected Credentials - https://github.com/advisories/GHSA-42r5-vhpq-m858
MariaDB has  possible SQL injection in Buffer parameter escaping under big5/gbk/sjis/cp932/gb18030 client charsets - https://github.com/advisories/GHSA-g5xc-5w98-jfvm
No fix available
node_modules/mariadb
  @prisma/adapter-mariadb  <=8.1.0-dev.4
  Depends on vulnerable versions of mariadb
  node_modules/@prisma/adapter-mariadb

mysql2  <=3.23.0
Severity: high
MySQL2: Auth Plugin Downgrade to mysql_clear_password Leaks Plaintext Credentials - https://github.com/advisories/GHSA-3f6p-5ww8-9rcr
MySQL2: Unbounded zlib inflate in compressed MySQL protocol handler allows decompression-bomb DoS - https://github.com/advisories/GHSA-rgwj-5xj2-c3m3
fix available via `npm audit fix --force`
Will install prisma@6.19.3, which is a breaking change
node_modules/mysql2

sharp  <0.35.4
Severity: high
sharp: Vulnerabilities in libheif: GHSA-g89c-p67h-r497 and GHSA-2jg2-4ch7-h545 - https://github.com/advisories/GHSA-rgj7-g3m4-5g8c
fix available via `npm audit fix`
node_modules/sharp

8 vulnerabilities (1 moderate, 7 high)

★この実行では npm audit fix を一切かけていません★(--force なしも含む)。 ★package-lock.json に差分が無いことを git diff --stat で確認済み★。

★mariadb コネクタの SQL インジェクション勧告(GHSA-g5xc-5w98-jfvm)について★

★★これは「アプリコードに生SQLが無い」では防げない層の話です★★ — ★コネクタがパラメータをエスケープする処理そのものの問題だからです★ (アプリ側の生SQL禁止は D-122 で ESLint ルール化済みですが、それとは別の層)。

★★ただし、影響を受けるのはクライアント文字セットが big5 / gbk / sjis / cp932 / gb18030 のときだけです★★。

★2026-09-13 の実測: 該当しません。★

grep -n "charset\|collation" .env.example .env.production.example lib/db/prisma.ts
# → ヒット0件
  • DATABASE_URL のひな型(.env.example / .env.production.example)は mysql://USER:PASSWORD@HOST:3306/DATABASE で、★クエリ文字列そのものがありません★
  • lib/db/prisma.ts は new PrismaMariaDb(databaseUrl) で、★オプションを渡していません★
  • ★★したがって既定(utf8mb4)で動き、勧告の条件に当たりません★★

★★本番の DATABASE_URL に charset= / collation= を足す場合は、この勧告を先に読んでください★★。 ★上記5つの文字セットを指定すると、この層の対策が無い状態になります★。

★No fix available の扱い(★申し送り済み★)★

★mariadb の3件は★上げても直りません★(No fix available)★。 ★★これは DFA のアプリコードでは閉じられないので、docs/handoff.md に インフラ/TeamWorks 向けの申し送りとして出してあります★★。