config, dotenv, dotenv-cli, and dotenv-safe are all npm packages designed to manage environment-specific configuration in Node.js applications, but they take different approaches. config uses structured JSON/YAML files per environment and supports hierarchical settings. dotenv loads a flat .env file of key-value pairs into process.env. dotenv-cli enables running commands with .env variables injected without modifying application code. dotenv-safe extends dotenv by enforcing that all required variables (defined in .env.example) are present in .env, failing fast if any are missing.
Managing environment-specific settings is a foundational concern in any Node.js application. The four packages under review — config, dotenv, dotenv-cli, and dotenv-safe — each approach this problem differently, with distinct philosophies around structure, safety, and developer workflow. Let’s examine how they work in practice and where each shines.
config assumes your app uses structured JSON or YAML files per environment (e.g., config/development.json, config/production.json). It merges defaults with environment overrides and provides a consistent API to access values.
// config: structured file per environment
// config/default.json
{ "db": { "host": "localhost" } }
// config/production.json
{ "db": { "host": "prod-db.example.com" } }
// In code
const config = require('config');
console.log(config.get('db.host')); // Picks correct host based on NODE_ENV
dotenv, by contrast, loads a flat .env file of key-value pairs into process.env. There’s no nesting or merging — just simple string assignments.
// .env
DB_HOST=localhost
API_KEY=secret123
// In code
require('dotenv').config();
console.log(process.env.DB_HOST); // 'localhost'
dotenv-cli doesn’t load variables into your app directly. Instead, it lets you run commands with .env values injected into the environment — useful for scripts or one-off tasks.
# dotenv-cli: run a command with .env loaded
npx dotenv -e .env node migrate.js
dotenv-safe works like dotenv but enforces required variables by comparing against an .env.example file. If a required key is missing, it throws an error at startup.
// .env.example (defines required keys)
DB_HOST=
API_KEY=
// .env (must include all keys from .env.example)
DB_HOST=localhost
API_KEY=secret123
// In code
require('dotenv-safe').config();
// Throws if .env is missing DB_HOST or API_KEY
config validates structure at runtime using config.util.loadFileConfigs(), but won’t prevent missing keys unless you explicitly check with has() or use TypeScript definitions.
const config = require('config');
if (!config.has('requiredService.url')) {
throw new Error('Missing requiredService.url');
}
dotenv offers no validation. If a key is missing from .env, process.env.MY_VAR will simply be undefined — which may cause subtle bugs later.
require('dotenv').config();
// No error if PORT is missing; app might crash later
const port = process.env.PORT || 3000;
dotenv-cli inherits dotenv’s lack of validation since it just wraps the same loading logic for CLI use.
# If .env is missing PORT, the script sees undefined
npx dotenv -e .env node server.js
dotenv-safe is built for fail-fast safety. It compares your .env against .env.example and exits immediately if required variables are absent.
// This will throw if .env lacks any key from .env.example
require('dotenv-safe').config({
allowEmptyValues: false // default behavior
});
config encourages a configuration-as-code approach. You commit all config files (with placeholders for secrets) and override only what’s needed per environment. This works well in containerized or PaaS environments where config is injected via files.
dotenv fits naturally into local development workflows where developers copy .env.example to .env and fill in values. It’s simple and widely understood, but requires discipline to avoid committing real secrets.
dotenv-cli shines when you need to run ad-hoc commands (tests, migrations, scripts) with environment variables without modifying your app’s startup code.
// package.json
{
"scripts": {
"migrate": "dotenv -e .env node ./scripts/migrate.js"
}
}
dotenv-safe adds a safety net to the .env workflow by ensuring your local or CI environment isn’t missing critical variables before the app even starts.
You deploy to dev, staging, and prod, each with complex nested settings (database clusters, feature flags, service endpoints).
configYou want something dead simple for local dev and Heroku-style env var deployment.
dotenvprocess.env natively.Your CI pipeline needs to run a migration script with database credentials, but you don’t want to modify the script itself.
dotenv-cli.env values into any command without touching application code.# In CI script
npx dotenv -e .env.staging node ./migrate.js
Your app must fail immediately if any required configuration is missing (e.g., financial or healthcare apps).
dotenv-safe| Package | Structure | Validation | Best For |
|---|---|---|---|
config | Nested files | Manual | Complex, multi-env applications |
dotenv | Flat .env | None | Simple apps, rapid prototyping |
dotenv-cli | Flat .env | None | CLI scripts and one-off commands |
dotenv-safe | Flat .env | Required keys | Safety-critical local/CI setups |
config..env convention, use dotenv — but add runtime checks for critical variables.dotenv-cli.dotenv-safe gives you peace of mind with minimal overhead.All four tools are actively maintained and solve real problems — the right choice depends entirely on your team’s workflow, deployment strategy, and risk tolerance.
Choose config if your application requires structured, hierarchical configuration across multiple environments (e.g., development, staging, production) with support for nested settings and file-based overrides. It’s ideal for enterprise or complex applications where configuration is managed as code and secrets are injected separately via infrastructure.
Choose dotenv if you need a simple, lightweight solution for loading environment variables from a .env file into process.env, especially for small-to-medium projects or rapid prototyping. It works well with platforms like Heroku that natively use environment variables, but requires manual validation for required keys.
Choose dotenv-cli when you need to run shell commands, scripts, or one-off tasks (like database migrations or tests) with environment variables loaded from a .env file without altering your application’s startup logic. It’s perfect for CI pipelines or developer tooling where config injection should be external to the app.
Choose dotenv-safe if you require strict validation that all necessary environment variables are defined before your application starts, using an .env.example file as a contract. It’s best suited for safety-critical applications or teams that want to catch missing configuration early in development or CI environments.
Node-config organizes hierarchical configurations for your app deployments.
It lets you define a set of default parameters, and extend them for different deployment environments (development, qa, staging, production, etc.).
Configurations are stored in configuration files within your application, and can be overridden and extended by environment variables, command line parameters, or external sources.
This gives your application a consistent configuration interface shared among a growing list of npm modules also using node-config.
The following examples are in JSON format, but configurations can be in other file formats.
Install in your app directory, and edit the default config file.
$ npm install config
$ mkdir config
$ vi config/default.json
{
// Customer module configs
"Customer": {
"dbConfig": {
"host": "localhost",
"port": 5984,
"dbName": "customers"
},
"credit": {
"initialLimit": 100,
// Set low for development
"initialDays": 1
}
}
}
Edit config overrides for production deployment:
$ vi config/production.json
{
"Customer": {
"dbConfig": {
"host": "prod-db-server"
},
"credit": {
"initialDays": 30
}
}
}
Use configs in your code:
const config = require('config');
//...
const dbConfig = config.get('Customer.dbConfig');
db.connect(dbConfig, ...);
if (config.has('optionalFeature.detail')) {
const detail = config.get('optionalFeature.detail');
//...
}
config.get() will throw an exception for undefined keys to help catch typos and missing values.
Use config.has() to test if a configuration value is defined.
Start your app server:
$ export NODE_ENV=production
$ node my-app.js
Running in this configuration, the port and dbName elements of dbConfig
will come from the default.json file, and the host element will
come from the production.json override file.
Type declarations are published under types/ and resolved via typesVersions. Subpath typings are included for config/async, config/defer, config/parser, config/raw, and config/lib/util in addition to the main config entrypoint.
If you still don't see what you are looking for, here are some more resources to check:
node-config contributors.May be freely distributed under the MIT license.
Copyright (c) 2010-2026 Loren West and other contributors