.envと.env.localを混ぜて本番が落ちた話。環境変数を型付きで守る設計
.envと.env.localの使い分け、.gitignore、zodでの起動時バリデーション、本番への注入、クライアント露出の境界まで。環境変数を事故らせない型を作る。
「ローカルでは完璧に動くのに、本番だけ起動しない」
去年の僕です。原因を追うのに半日溶かしました。犯人は環境変数。ローカルの .env.local に DATABASE_URL を書いていたのに、本番の注入先には入れ忘れていた。アプリは何も言わずに undefined を握ったまま起動を試み、DB接続の奥のほうで意味不明なエラーを吐いて死ぬ。ログを見ても「接続に失敗」としか書いていない。そりゃ追えません。
環境変数のつらいところは、間違えても静かに動いてしまうことです。値が空でもアプリは起動しようとするし、.env と .env.local の優先順位を勘違いしていても、その日はたまたま動く。事故が表に出るのは、たいてい本番デプロイの直後か、新メンバーが git clone した直後です。
この記事は、その「静かな事故」を起動時に大きな音で止めるための設計です。.env ファミリーの使い分け、.gitignore の線引き、zodでの型付き検証、本番への注入、そしてクライアントに何を見せて何を隠すか。順番に組み立てます。
この記事の要点
.env(共有のデフォルト)と.env.local(自分だけの上書き、Git管理外)は役割が違う。混ぜると事故る- 守りの起点は
.gitignore。「秘密値の入るファイルだけ無視、雛形は追跡」を1ブロックで宣言する - 12-factorの「設定はコードでなく環境から」を、zodの起動時バリデーションで強制する。足りなければ即落とす
- クライアントに出る変数(
PUBLIC_/NEXT_PUBLIC_)は全世界に公開されると思って扱う。秘密値を絶対に置かない
シークレットそのものの保管場所やローテーション手順は範囲が広いので、Claude Codeで始めるシークレット管理に分けてあります。この記事は「環境変数という入れ物の扱い方」に絞ります。
.envファミリーの使い分けを最初に決める
最初の事故の多くは、ファイルの役割を曖昧にしたまま増やすことから起きます。.env と .env.local と .env.production、何が違うのか。先に表で固定します。
| ファイル | 役割 | Gitに入れる? | 本物の秘密値 |
|---|---|---|---|
.env.example | 必要なキー名の雛形(チェックリスト) | 入れる | 入れない |
.env | チーム共通のデフォルト値 | 原則入れない | 入れない |
.env.local | 自分のPCだけの上書き | 入れない | 入れてよい(ローカル開発キー) |
.env.production.example | 本番に必要なキー名の雛形 | 入れる | 入れない |
ポイントは2つです。ひとつ、本物の秘密値が入るのは .env.local だけにする。.env を「共通だから」と本番キーで埋めると、誰かがうっかりコミットした瞬間に終わります。ふたつ、.env.example と .env.production.example は値ではなくキー名のリストとして育てる。新メンバーはこれをコピーして自分の .env.local を作る、という流れにします。
dotenv系ツールやVite、Next.jsの多くは「.env.local が .env を上書きする」優先順位を持っています。僕が半日溶かした事故も、ローカルでは .env.local が効いていて気づかなかったのが遠因でした。ローカルで効いている値が、本番でも効いているとは限らない。ここを骨身に刻んでおきます。
.gitignoreで「秘密値だけ」を無視する
環境変数の漏洩で一番多いのは、難しいハッキングではなく git add . です。.env.local をうっかりコミットして、APIキーがGitHubの履歴に焼き付く。一度履歴に入った値は、ファイルを消しても過去のコミットから読めてしまいます。
だから守りは .gitignore から始めます。雛形だけ追跡して、秘密値の入るファイルは全部無視する。これを1ブロックで宣言します。
# .gitignore
# 秘密値の入るファイルは全部無視
.env
.env.*
# ただし雛形(キー名だけ)は追跡する
!.env.example
!.env.production.example
# Cloudflare Workers のローカル secret
.dev.vars
.dev.vars.*
! で始まる行は「無視しない(例外)」の意味です。.env.* で全部止めてから、雛形だけ穴を開ける。この順番が大事で、逆に書くと例外が効きません。
もし過去に一度コミットしてしまったら、.gitignore を足すだけでは不十分です。Gitの追跡から外したうえで、漏れた値は無効化して作り直す。履歴の書き換えと値の失効はセットです。このあたりの失効・差し替えの手順はClaude Codeで始めるシークレット管理にまとめています。
12-factorの「設定をコードから引き剥がす」
なぜわざわざ環境変数なのか。コードに直接 const dbUrl = "postgresql://..." と書けば動くのに。
答えはThe Twelve-Factor AppのConfigが端的です。設定はコードに埋め込まず環境から注入する。理由は単純で、コードは全環境で同じ、設定だけが環境ごとに変わるから。ローカル・ステージング・本番で違うのはDBのURLやキーであって、ロジックではありません。値をコードに焼くと、環境ごとにコードを分岐させる羽目になり、すぐ破綻します。
ただ「環境から注入」をそのまま信じると落とし穴があります。環境変数は全部ただの文字列で、しかも空でもエラーにならない。PORT を数値だと思って計算に使うと、"3000" という文字列が紛れ込む。DATABASE_URL を入れ忘れても undefined のまま起動が進む。12-factorは「どこに置くか」は教えてくれますが、「正しく入っているか」は自分で確かめないといけません。
そこで次の柱、起動時バリデーションです。
zodで起動時に型付き検証する(コピペで動く)
僕がいま全プロジェクトでやっているのは、アプリが読む環境変数を1ファイルに集約して、起動の最初にzodで検査することです。zodは「文字列で渡ってくる値を、アプリが期待する型に変換しながら検査する道具」。足りなければ・形が違えば、画面に進む前にその場で落とす。
まず入れます。
npm install zod dotenv
npm install -D tsx typescript @types/node
そして検証ファイル。これがアプリの「環境変数の正面玄関」です。process.env を直接読むのはこのファイルだけ、という約束にします。
// src/config/env.ts
import "dotenv/config";
import { z } from "zod";
// 環境変数を「期待する型」で宣言する。文字列以外は coerce で変換。
const envSchema = z.object({
APP_ENV: z.enum(["local", "staging", "production"]).default("local"),
// PORT は環境変数では文字列なので数値に変換してから範囲チェック
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
// URL 形式かどうかまで見る。入れ忘れ・打ち間違いをここで弾く
APP_ORIGIN: z.string().url(),
DATABASE_URL: z.string().url(),
ANTHROPIC_API_KEY: z.string().min(20, "ANTHROPIC_API_KEY が短すぎます"),
WEBHOOK_SECRET: z.string().min(32, "WEBHOOK_SECRET は32文字以上にしてください"),
// クライアントに出してよい公開キー。空でも許す。
PUBLIC_ANALYTICS_KEY: z.string().optional(),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
// どのキーが、なぜダメかを1行ずつ表示してから落とす
console.error("環境変数の検証に失敗しました:");
for (const issue of parsed.error.issues) {
console.error(`- ${issue.path.join(".")}: ${issue.message}`);
}
process.exit(1);
}
// 凍結して、以後どこからも書き換えられないようにする
export const env = Object.freeze(parsed.data);
動作確認はこれだけです。
cp .env.example .env.local # 雛形から自分用を作る
npx tsx src/config/env.ts
DATABASE_URL をわざと消して実行すると、起動が進まずに「DATABASE_URL: Required」と出て止まります。これが効くんです。「本番だけ静かに落ちる」が「全環境で起動時にうるさく落ちる」に変わる。半日のデバッグが、5秒のエラーメッセージになります。
アプリの他の場所では import { env } from "./config/env" で読みます。env.PORT はもう number 型なので、計算に使っても文字列が紛れません。zodの強みは、検証と同時にTypeScriptの型が付くこと。env. と打てばエディタが候補を出してくれます。
Claude Codeに既存リポジトリを直してもらうなら、こう頼むと漏れを拾いやすいです。
このリポジトリで process.env を直接読んでいる箇所を全部探してください。
src/config/env.ts 以外で読んでいたら、env から読む形に修正案を出してください。
本物のAPIキーや本番のDB URLは読み込まず、キー名と検証条件だけで作業してください。
最後の1行が肝です。Claude Codeに秘密値そのものを貼る必要はありません。必要なのはキー名と「何文字以上か」「URL形式か」といった条件だけ。実装依頼と秘密の共有を分ける——この線引きは初心者がまず守る安全対策でも繰り返し書いています。
クライアントに何を見せて何を隠すか
ここが環境変数で一番事故るポイントかもしれません。フロントエンドにビルドされる変数は、全世界に公開されると思ってください。ブラウザの開発者ツールを開けば、誰でも読めます。
主要フレームワークは「これは公開してよい」と分かるよう、プレフィックスでクライアントに出す変数を区別します。
| フレームワーク | クライアントに出るプレフィックス | 例 |
|---|---|---|
| Next.js | NEXT_PUBLIC_ | NEXT_PUBLIC_GA_ID |
| Vite / Astro | PUBLIC_ または VITE_ | PUBLIC_ANALYTICS_KEY |
| Create React App | REACT_APP_ | REACT_APP_API_BASE |
ルールは1つだけ。このプレフィックスが付いた変数に秘密値を入れない。NEXT_PUBLIC_STRIPE_SECRET_KEY なんて名前を見たら、それはもう漏洩です。公開キー(GoogleアナリティクスのIDや公開API base URL)はここでいい。秘密キー(Stripeのsecret、DBのURL、署名secret)はプレフィックスを付けず、サーバー側でだけ読む。
詳しくはNext.jsのEnvironment VariablesやViteのEnv Variablesの公式に明文化されています。「プレフィックスの意味を知らずに秘密値を入れる」のは、入門者がやりがちで、しかも被害が大きい。覚えておくと一生効きます。
サーバー側の値とクライアント側の値を、コードのうえでも分けておくと安全です。
// src/config/public-env.ts
import { env } from "./env";
// クライアントに渡してよい値だけを、明示的に列挙する関数。
// ここに DATABASE_URL や *_SECRET を絶対に書かない。
export function publicEnv() {
return {
APP_ENV: env.APP_ENV,
APP_ORIGIN: env.APP_ORIGIN,
PUBLIC_ANALYTICS_KEY: env.PUBLIC_ANALYTICS_KEY ?? "",
};
}
env 全体をクライアントに渡すのではなく、「出してよいものだけ」を手で選ぶ。ホワイトリスト方式です。新しい秘密値を足したとき、うっかり混入する事故を防げます。
本番にはコピーではなく注入する
ローカルの .env.local を本番サーバーにアップロードする——これは絶対にやめてください。ファイルが残り続けるし、誰がいつ更新したか分からなくなります。
12-factorの考え方どおり、本番では各プラットフォームの仕組みから値を注入します。やり方はサービスごとに違いますが、原則は同じ。「値の一覧はコード(雛形)で管理し、本物の値はプラットフォーム側に置く」。
- Vercel: Production / Preview / Development で環境を分けて登録する。ビルド時に要る値と実行時に要る値を混同しない
- Cloudflare Workers: 平文の変数ではなく secret として登録し、Worker の
envから受け取る - Docker:
DockerfileにENV API_KEY=...と書かない(イメージ履歴に残る)。ローカル確認は--env-fileで十分
Docker をローカルで動かすだけならこれで足ります。
# ローカル確認のみ。本番は実行基盤の secret を使う
docker run --rm --env-file .env.local my-app:latest
そして本番デプロイの前に、必ず起動時バリデーションを通します。CIで npx tsx src/config/env.ts を1ステップ走らせるだけで、「本番に必要なキーが揃っているか」をデプロイ前に確認できる。僕が半日溶かしたあの事故は、このCIステップが1行あれば、PRの時点で赤くなって止まっていました。
よくある失敗と対策
僕や周りが実際に踏んだものを並べます。
| 失敗 | 何が起きた | 対策 |
|---|---|---|
.env.local をコミットした | APIキーが履歴に焼き付いた | .gitignore を先に整え、漏れた値は失効させる |
NEXT_PUBLIC_ に秘密値を入れた | ブラウザから誰でも読めた | プレフィックス付きは公開前提と覚える |
PORT を文字列のまま計算した | 数値演算が壊れた | z.coerce.number() で起動時に変換する |
| 本番だけ起動しない | 必須キーが本番に未登録 | CIで env.ts を実行し事前に落とす |
.env を本番にアップした | 古い値が残り続けた | コピーせずプラットフォームから注入する |
| Claude Codeに本物のキーを貼った | プロンプト履歴に残った | キー名と検証条件だけ渡す |
共通するのは、どれも「その場では動いてしまう」こと。だから人間の注意力では防ぎきれません。.gitignore とzodの起動時検証という、機械が止めてくれる仕組みを先に置くのが結局いちばん速い。
よくある質問
Q. .env と .env.local、どちらに書けばいいですか?
チーム共通の無害なデフォルト(LOG_LEVEL=info など)は .env、自分のPC固有の値や開発用キーは .env.local です。本物の秘密値は .env.local だけに。.env.local は .gitignore で必ず無視します。
Q. dotenv-flowやdotenv-expandは使うべき?
小〜中規模なら標準の dotenv とzod検証で十分です。複数環境の合成やネスト参照が本当に必要になってから足せばよく、最初から多機能ツールを入れる必要はありません。
Q. zodの代わりにenvalidやt3-envでもいい? かまいません。狙いは同じ「起動時に型付きで検証して、足りなければ落とす」こと。すでにzodを使っているプロジェクトならzodで揃えるのが楽で、型もそのまま付きます。
Q. クライアントに出した公開キーは隠せますか? 隠せません。プレフィックス付きの変数はビルド成果物に焼き込まれ、ブラウザから読めます。GoogleアナリティクスIDのように「公開されても害がない値」だけを置いてください。秘密にしたい値はサーバー側でだけ読みます。
Q. シークレットの保管場所やローテーションは? この記事の範囲外なので、Secret Managerや差し替え手順はClaude Codeで始めるシークレット管理に分けています。認証トークンの鍵まわりはClaude CodeでJWT認証を実装するも参考になります。
実際に試した結果
この型を検証用リポジトリに入れてから、環境変数まわりの「静かな事故」がはっきり減りました。一番効いたのは、.env.example を整えることより、zodで起動時に落とすことです。キーが足りない状態で画面に進めないので、レビューやデプロイの前に必ず気づく。
具体的には3つ止まりました。「本番だけ DATABASE_URL 未登録」がCIで赤くなった。「WEBHOOK_SECRET が32文字未満」が起動時に弾かれた。「公開キーのつもりで秘密値を PUBLIC_ に入れていた」のがコードレビューで見つかった。どれも昔の僕なら本番で踏んでいた事故です。
環境変数は地味です。でも、ここが緩いとアプリ全体が緩くなる。賢い設定ツールを探すより、.gitignore の数行とzodの数十行を先に置く。半日のデバッグを5秒のエラーに変える投資だと思えば、安いものです。
もっと深く整えたい人へ。ClaudeCodeLabでは環境変数テンプレートの整備やセキュリティレビューも相談できます。次の一歩は教材一覧からどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。