# 本番デプロイ手順

開発側のセットアップは `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）

   ```bash
   cp .env.production.example .env.local
   ```

2. 各値を埋める。★**`AUTH_TOKEN` は必ず変更する**★
   （`changeme` のままだと手順3で落ちます）

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

   ```bash
   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` 相当にしてください。

```nginx
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）★★:

```nginx
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 の実測: 該当しません。**★

```bash
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 向けの申し送りとして出してあります★★。
