iTranslated by AI
Best Practices for Environment Variables in Web Application Development
Addendum
In this article, I introduced the practice of not making the code dependent on .env. I believe this is generally the right approach, but some frameworks provide convenience through deep integration with .env. In such cases, I have recently felt that it is fine to utilize it effectively instead of forcing yourself to move away from .env. However, if the framework is not deeply integrated with .env, I feel it is better to inject them as environment variables at startup rather than introducing libraries like dotenv.
I will explain using Node.js as an example. In Node.js, environment variables can be retrieved via process.env.ENVIRONMENT_VARIABLE_NAME. Also, there is a convention of setting the environment variable NODE_ENV to development, test, or production for development, testing, and production environments, respectively.
Make the Application Code Unaware of the Current Environment (Development | Staging | Production)
This means not creating if branches in the code based on environment identification variables (in this case, NODE_ENV). Let's create the necessary configurations for each environment outside of the application code!
bad
if(Development environment){
const logger = new Logger({
level: 'debug'
});
} else if (Staging environment){
const logger = new Logger({
level: 'info
});
} else if (Production environment){
const logger = new Logger({
level: 'error'
});
}
good
LOG_LEVEL=debug
LOG_LEVEL=info
LOG_LEVEL=error
const logger = new Logger({
level: process.env.LOG_LEVEL
});
Unfortunately, depending on the framework or DB client library you use, some may force specific configurations for development, test, and production environments. In such cases, you can handle it by injecting the same environment variables into all environments.
bad
module.exports = {
development: {
username: 'username for development',
password: 'password for development',
database: 'database for development',
host: 'host for development',
},
test: {
username: 'username for test',
password: 'password for test',
database: 'database for test',
host: 'host for test',
},
production: {
username: 'username for production',
password: 'password for production',
database: 'database for production',
host: 'host for production',
},
};
good
DB_USER_NAME=dev
DB_PASSWORD=password
DB_NAME=development
DB_HOST=localhost...
DB_USER_NAME=stg
DB_PASSWORD=secret
DB_NAME=staging
DB_HOST=...
DB_USER_NAME=prod
DB_PASSWORD=super_secret
DB_NAME=prod
DB_HOST=...
module.exports = {
development: {
username: process.env.DB_USER_NAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
},
test: {
username: process.env.DB_USER_NAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
},
production: {
username: process.env.DB_USER_NAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
},
};
Don't Let the App Fetch Environment Variables Itself (Don't Make the App Dependent on .env)
Many people might be using .env files or frameworks that load .env file environments by default. While it's fine to use the .env file itself, you should turn off the setting that automatically loads .env files from the application side. This might be a framework setting or a library like dotenv. Please remove them.
The reason for doing this is that I don't want the application itself to actively retrieve environment variables. This is similar to "Make the Application Code Unaware of the Current Environment (Development | Staging | Production)," but it's a way to prevent the app from deciding which .env file to depend on (or not). I want the application side to rely solely on the environment variables that are simply passed to it. By doing this, the app doesn't need to have any judgment logic or conditional branches about which environment it should depend on.
Keep the app itself pure, and if you need to depend on .env-style files, inject them from the outside using some kind of library. This will be explained in the later section, "Inject .env settings from the outside in the development environment."
Keeping the app pure also has the benefit of making it easier to set environment variables in the production environment.
bad
// Load environment variables from .env
require('dotenv').config();
console.log(process.env.DB_HOST);
good
// Load actual environment variables
console.log(process.env.DB_HOST);
Injecting .env Settings from the Outside in Development Environments
We have removed the app's dependency on .env files, but .env remains very convenient for easily tweaking local environments. We want to use .env files in development environments and use standard environment variables in production. How should we handle this?
In my case, I separate the application server startup scripts. For Node.js, you can specify commands in the scripts section of the package.json file and run them using npm run <command-name>. When running locally, use npm run local-start to load .env and start the server. For production, simply run npm run start.
{
"name": "my app",
"version": "3.6.0",
"description": "My webpack project",
"main": "index.js",
"scripts": {
// Use environment variables of the current environment as is
"start": "nest start",
"build": "nest build",
"test": "jest",
// env-cmd is a Node.js library that loads .env and sets them as environment variables
"local-start": "env-cmd -f .env npm run start",
"local-build": "env-cmd -f .env npm run build",
"local-test": "env-cmd -f .env.testing npm run test"
}
}
Do Not Commit .env to Git
I'll mention this just in case. Since .env files often handle sensitive information, ensure they are properly excluded via .gitignore.
Ideally, you should only keep dummy data for sensitive items in your .env. To facilitate this, avoid using external services that require high-security credentials during development. Instead, use local Docker containers to mock them (e.g., using the localstack image provided by AWS instead of setting real AWS_SECRET_KEY values). If you must use an external service, verify that .env is in .gitignore and modify only your local .env. Keep only dummy data in the .env.local file mentioned below!
bad
SECRET_KEY=actual_value
good
SECRET_KEY=dummy_value
You might be wondering how to share development environment variables if .env shouldn't be committed to Git. I recommend the following:
- Create a
.env.localfile and manage it with Git. - Whenever a new member joins, have them copy
.env.localto.env. - When you want to use a new environment variable, update both
.envand.env.local.
Stop the Server if Mandatory Environment Variables are Missing
In Node.js, the type of process.env.ENVIRONMENT_VARIABLE_NAME is either string or undefined (equivalent to null in other languages). This is because it is unknown whether the environment variable itself has been set, thus it can become undefined. However, this can be problematic. If mandatory configuration values required by the app or internal services are undefined, it can lead to issues. It is safer to prevent the app server from starting if mandatory configuration values are missing.
bad
const paymentService = new PaymentService({
// Using only process.env.SECRET_KEY could result in undefined, leading to a compilation error.
// Therefore, we provide an empty string as a default if process.env.SECRET_KEY is not set.
secret: process.env.SECRET_KEY ?? ''
})
good
if(!process.env.SECRET_KEY){
console.error('Environment variable SECRET_KEY is not set!');
process.exit(); // Stop the server
}
const paymentService = new PaymentService({
secret: process.env.SECRET_KEY
})
Wrap Environment Variable Access Instead of Directly Using process.env to Make It Easier to Use
When handling environment variables, I believe there are two types of configuration items:
- Mandatory configuration items
- Optional configuration items (where a default value is used if not set)
As explained in the previous section "Stop the Server if Mandatory Environment Variables are Missing," "Mandatory configuration items" require logic to stop the server if they are missing. This means logic like the following ends up scattered throughout the code:
if(!process.env.SECRET_KEY){
console.error('Environment variable SECRET_KEY is not set!');
process.exit(); // Stop the server
}
const paymentService = new PaymentService({
secret: process.env.SECRET_KEY
})
"Optional configuration items" require specifying default values. This means logic like the following gets scattered. Furthermore, you might add logic to convert a string to an Int, which tends to make the code complex. Also, default values for the same configuration item are basically the same, yet the same code ends up scattered in various places...
let port: number = 5000; // Default value
if(process.env.PAYMENT_PORT){
// JS is tricky and returns a number type variable called NaN if it cannot be successfully converted to an int.
const parsed = Number.parseInt(process.env.PAYMENT_PORT);
// Assign if it is not NaN.
if(!Number.isNaN(parsed)){
port = parsed
}
}
const paymentService = new PaymentService({
port: port
})
As shown above, boilerplates for accessing environment variables are scattered all over the code. It would be ideal to consolidate them into one place and eliminate the boilerplate. Therefore, instead of accessing environment variables directly, create a wrapper and perform preprocessing there before use.
Configuration
const convertIntOrDefault = (
raw: string | undefined,
defaultValue: number,
): number => {
if (!raw) return defaultValue;
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) return defaultValue;
return parsed;
};
if (!process.env.HOST_DOMAIN) {
console.error('HOST_DOMAIN environmental variable is missing!');
process.exit();
}
export const appConfig = {
app: {
// [Mandatory] Service domain name
hostDomain: process.env.HOST_DOMAIN,
// Port number to listen on
port: convertIntOrDefault(process.env.PORT, 3000),
},
}
Usage
import {appConfig} from './config';
console.log(appConfig.app.hostDomain);
If your framework has features to handle configurations, it might be a good idea to register them there.
Those were the best practices for environment variables in web app development~
Bonus
I also wrote an article about environment variables specifically for NestJS!
Discussion
うーん、新しい環境が必要なら、development.yaml, test.yaml, production.yamlがあるところに、staging.yamlを追加します、という方式(大抵のFW/ライブラリ/ミドルウェアが採用している方式)がいい気がします。
どの環境でも同じ環境変数を参照する仕組みにする場合、結局の所、staging.envとかstaging_setenv.shが必要になりませんか?
コメントありがとうございます!
はい、そうですね、僕もそう思います。ちょっとわかりにくい表現をしてしまいました。ここで伝えたかったことは、アプリケーションコード自体に自分がいまどの環境にいるのかを意識させないということでした。これは言い換えればコードの中に環境識別情報を変数(今回で言うところのNODE_ENV)でif分岐させないという意味です。
例えば以下のようなコードですね。こうなると環境が増えるたびにコード内にNODE_ENVをもとにした分岐が増えていきます。
それよりも、その環境による差分を、
.env.developmentや.env.productionやenv.stagingに入れてしまってアプリケーションコードは素直にその環境変数を受け入れるようにしようという意味でした。
記事の方をもう少し表現変えたいと思います^^
内容変更
変更箇所1
表現の変更
アプリの環境(例えばNODE_ENV)によって設定を分岐しない
->
アプリケーションコードに自分が今いる環境(開発|ステージング|本番)を意識させない
削除
よくDBとかで開発環境・テスト環境・本番環境ごとに設定を分岐させることがあります。The Twelve-Factor Appでも言及されているように、環境はその3つだけではなく、開発環境のなかでも設定値のバリエーションが生まれたりします。いっそのこと開発環境・テスト環境・本番環境と区別せずに、設定の値だけ環境変数を増やすほうがこのバリエーションに対応できます。ただし、環境を扱う環境変数(NODE_ENV)で分岐させたほうが素直な場合も極稀にあります(フレームワークが
NODE_ENV環境によって挙動を変えるときとか)。追記
これはつまり、コード内で環境識別変数(今回で言うところのNODE_ENV)によってif分岐を作らないという意味です。各環境にどのような設定が入るかはアプリケーションコード外にその種類分作成しましょう!
変更箇所2
表現の変更
アプリ自体を
.envに依存させない->
アプリに自分から環境変数を取りに行かせない。
削除
.envを読み込む設定の場合、開発環境ではいいのですが、本番環境だと.envを作成管理しなければいけません。できれば、本番環境はその環境の環境変数をそのまま使うほうが扱いやすいです。各本番環境ごとに、それぞれの環境変数を設定するだけで済みます。追記
なぜこのようなことをするかというと、アプリ自体に環境変数を能動的に取得してほしくないからです。これは「アプリケーションコードに自分が今いる環境(開発|ステージング|本番)を意識させない」と似ていますが、アプリ側にどの
.envに依存するのか、依存しないのかの判断をさせないための防御手段です。アプリ側には素直に渡された環境変数のみに依存してほしいのです。アプリ自体はピュアに保っておき、どの
.envに依存するのか、依存しないのかのバリエーションは外部が注入してあげます。アプリ自体をピュアに保っていると、本番環境の環境変数を設定しやすいというメリットもあります。
とても参考になるいい記事でした。ありがとうございます。
一点だけ誤字を見つけたので共有します。
誤
基本.envは
気密性の高い項目にはダミーデータしか置かないほうがいいですね。正
基本.envは
機密性の高い項目にはダミーデータしか置かないほうがいいですね。まさに
APP_ENVやらNODE_ENVを量産して地獄をみた経験があるので首がもげました。「あえて知らせないほうがうまくいく」ことがあるのは人間界でもコンピューター界でも一緒ですねぇ...