開発環境では動くのに、本番環境では動かない。
新しく参加したメンバーだけ、なぜかアプリを起動できない。
こうした問題を追っていくと、コードではなく「設定の扱い方」が原因だった、ということがあります。
環境変数は、process.env で値を取り出せば使えます。ただ、読み込み方や初期値の決め方が曖昧なままだと、環境が増えるにつれて管理が難しくなります。
僕が大切にしているのは、アプリケーションには、渡された設定を使うことに集中してもらうという考え方です。
この記事では、Node.jsを例に、環境変数を扱うときに意識したい設計を整理します。コード例は、主にサーバー側の処理を想定しています。
1. 環境名よりも「必要な設定値」を渡す
たとえば、ログの出力レベルを環境ごとに変えたいとします。
次のように書くと、アプリケーション側に「どの環境では、どう動くべきか」という知識が入ります。
// Loggerは利用するロガーを表す例です。
const level =
process.env.NODE_ENV === 'production'
? 'error'
: process.env.NODE_ENV === 'staging'
? 'info'
: 'debug';
const logger = new Logger({ level });
この状態で検証環境が増えると、コードの修正が必要になるかもしれません。
そこで、ログレベルそのものを環境変数として渡します。
const logger = new Logger({
level: process.env.LOG_LEVEL,
});
設定は、起動する側で決めます。
| 実行環境 | LOG_LEVELの設定例 |
|---|---|
| ローカル開発 | debug |
| ステージング | info |
| 本番 | error |
これは設計の例なので、本番で運用上必要な情報があるなら、info などを選びます。大事なのは、環境名と設定値の対応をアプリのあちこちに埋め込まないことです。
なお、NODE_ENV はライブラリやフレームワークの動作にも影響します。独自の環境名を自由に入れる用途とは分けて考え、本番に近い検証では本番と同じ実行モードを使うと差を減らせます。
2. .envの読み込みを、起動処理へ寄せる
.env は便利です。ローカルの接続先やポート番号を手軽に変更できます。
一方で、アプリケーションコードが読み込むファイルを選び始めると、設定ファイルの場所や命名規則まで意識することになります。
自分で読み込み方式を選べるプロジェクトでは、僕はこの処理を起動コマンド側へ寄せるのが好みです。
// アプリケーションコードは、渡された値を参照する。
console.log(process.env.DB_HOST);
Node.jsには、起動時にファイルから環境変数を読み込む --env-file オプションがあります。Node.js 20.6.0で追加された機能なので、利用するバージョンの対応を確認してください。
node --env-file=.env dist/index.js
TypeScriptなどのビルド済みファイルを dist/index.js に出力する構成なら、package.json のscriptsは次のようにできます。
{
"scripts": {
"start": "node dist/index.js",
"start:local": "node --env-file=.env dist/index.js"
}
}
ローカルでは npm run start:local を使い、本番では実行基盤から環境変数を渡して npm start を実行します。ビルドは別途済ませておく想定です。
この方式なら、アプリケーション側に .env の存在を前提とした処理を置かずに済みます。
なお、Node.jsの --env-file では、同名の変数がすでに実行環境にある場合、そちらが優先されます。設定が反映されないときは、値の優先順位も確認しておくと安心です。
3. フレームワークの標準機能は、無理に外さない
ここまで読むと、「dotenvやフレームワークの自動読み込みは、すべて外すべきなのか」と感じるかもしれません。
僕は、そこまで一律にする必要はないと思っています。
たとえばNext.jsには、.env 系ファイルを読み込む仕組みが標準で用意されています。こうした機能を前提に構成されたプロジェクトでは、その仕組みを理解して利用するほうが自然な場合もあります。
自分で起動方式を決められるなら、起動時に設定を渡す。フレームワークと設定機構が深く結びついているなら、標準の仕組みを活かす。
読み込み口を増やしすぎず、どこから、どの優先順位で設定が入るのか説明できる状態にしておきたいところです。
また、フロントエンドへ渡す設定は別の注意が必要です。Next.jsの NEXT_PUBLIC_ 付き変数はブラウザー向けのJavaScriptに埋め込まれるため、秘密鍵やパスワードを入れてはいけません。これらの値は通常、ビルド時に確定する点にも注意してください。
4. 共有するのは、秘密情報を含まない設定のひな形
実際の認証情報が入った .env をGitへコミットするのは避けます。
ただ、何も共有しないと、新しいメンバーは必要な設定項目が分かりません。そこで、共有用のひな形として .env.example を用意します。
# .env.example:Gitで共有するひな形
DB_HOST=localhost
DB_USER_NAME=local_user
DB_PASSWORD=local_only_dummy
DB_NAME=sample_app
PORT=3000
LOG_LEVEL=debug
PAYMENT_SECRET_KEY=
.gitignore では、実際に使うファイルを除外し、ひな形だけ例外にします。
.env
.env.*
!.env.example
新しいメンバーは、ひな形を .env としてコピーし、必要な値を設定します。項目を追加したときは、ひな形と説明も更新します。
共有用ファイルを .env.local と呼ぶこともできますが、Next.jsなどでは個人用の上書き設定として扱われます。役割の混同を避けるため、ここでは .env.example を使います。
開発用DBやモックで済む部分には、ローカル専用の値を使うと共有しやすくなります。外部サービスの実際のキーが必要なら、Gitに含めず、用途を限定した開発用の認証情報を管理します。
なお、すでに追跡されているファイルは、.gitignore に追加しただけでは追跡が止まりません。秘密情報をコミットしてしまった場合は、ファイルの削除に加えて、認証情報の失効・再発行も必要です。
5. 必須設定がなければ、処理を始める前に止める
設定値がないとき、とりあえず空文字を入れたくなることがあります。
const paymentService = new PaymentService({
secret: process.env.PAYMENT_SECRET_KEY ?? '',
});
ただ、決済に必要なキーが空でも起動できてしまうと、実際に決済するまで不備に気づけないかもしれません。
必須項目は、起動時に確認します。
const secret = process.env.PAYMENT_SECRET_KEY;
if (secret === undefined || secret.trim() === '') {
throw new Error('PAYMENT_SECRET_KEYが設定されていません');
}
const paymentService = new PaymentService({ secret });
この検証は、サーバーがリクエストを受け付ける前に実行します。例外を握りつぶさず、起動失敗として扱います。
明示的にプロセスを終了するなら、process.exit(1) のように異常終了のコードを指定します。エラーメッセージには設定項目名だけを含め、秘密情報の値そのものは出さないようにします。
6. 型変換と検証を、一か所にまとめる
process.env から得る値は、基本的に文字列か undefined です。
そのため、ポート番号の数値変換や、未設定時の初期値が必要になります。これを各サービスで繰り返すと、同じ項目なのに扱いが変わってしまいます。
僕は、設定を扱うモジュールにまとめるようにしています。
// config.ts
function required(name: string): string {
const value = process.env[name];
if (value === undefined || value.trim() === '') {
throw new Error(`${name}が設定されていません`);
}
return value;
}
function readPort(raw: string | undefined): number {
if (raw === undefined) return 3000;
// 「3000abc」や空文字なども、不正な設定として扱う。
if (!/^[0-9]+$/.test(raw)) {
throw new Error('PORTには整数を指定してください');
}
const port = Number(raw);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORTは1〜65535の範囲で指定してください');
}
return port;
}
export const appConfig = {
port: readPort(process.env.PORT),
paymentSecret: required('PAYMENT_SECRET_KEY'),
};
使う側は、検証済みの設定を参照します。
import { appConfig } from './config';
const paymentService = new PaymentService({
secret: appConfig.paymentSecret,
});
console.log(`起動ポート: ${appConfig.port}`);
この例では、「未設定なら初期値を使う」と「設定されている値が不正なら止める」を分けています。parseInt('3000abc', 10) は 3000 を返すため、数値に変換できたかだけでなく、入力全体や許容範囲も見るようにしています。
設定項目が増えたら、スキーマ検証ライブラリやフレームワークの設定機能を使うのもよいと思います。
7. 環境別の設定を求められても、実際の値は外から渡す
ORMやCLIによっては、development、test、production の設定を並べる形式を求められます。
そうした場合も、接続先やパスワードをコードへ直接書く必要はありません。たとえば、その形式を使うSequelize CLIの設定なら、次のように共通化できます。
// sequelizeConfig.js:CommonJS形式の例
// 実際のプロジェクトでは必須項目の検証も追加します。
const databaseConfig = {
username: process.env.DB_USER_NAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
dialect: 'postgres',
};
module.exports = {
development: { ...databaseConfig },
test: { ...databaseConfig },
production: { ...databaseConfig },
};
どの設定欄でも同じ「環境変数名」を参照し、実際の値は実行環境ごとに変えます。同じ認証情報やデータベースを全環境で共有する、という意味ではありません。
また、マイグレーションなどのCLIは、アプリ本体とは別プロセスで動きます。アプリ用の起動設定だけで安心せず、CLI側にも必要な環境変数を渡します。TLSなど環境ごとに必要な接続設定も、利用するDBに合わせて別途整えます。
おわりに
環境変数の管理では、ファイル名をそろえること以上に、設定の責任をどこへ置くかが大切だと思います。
起動する側で値を用意する。アプリケーションは、受け取った値を最初に検証して使う。共有用のひな形には、必要な項目と安全なサンプルを残す。
この境界がはっきりすると、環境が増えても、設定の変更箇所を追いやすくなります。
フレームワークの便利な機能も使いながら、自分たちのプロジェクトで無理なく続けられる形を選びたいですね。
参考資料