config, dotenv, dotenv-expand, and dotenv-safe are all Node.js packages designed to manage application configuration and environment variables, but they approach the problem differently. config provides a hierarchical, file-based configuration system that supports multiple environments through structured JSON, YAML, or JS files. dotenv loads environment variables from a .env file into process.env, offering a simple flat key-value approach. dotenv-expand extends dotenv by enabling variable interpolation within .env files (e.g., URL=http://${HOST}:${PORT}). dotenv-safe enhances dotenv with validation to ensure required environment variables are present, throwing errors if expected keys are missing. All four are commonly used in backend or full-stack JavaScript applications to separate configuration from code.
Managing environment-specific settings is a foundational concern in any production-grade application. The four packages under review — config, dotenv, dotenv-expand, and dotenv-safe — all aim to solve this problem but with fundamentally different philosophies, feature sets, and integration models. Let’s break down how they work, where they overlap, and when to use which.
config assumes your app needs structured, hierarchical configuration that varies by deployment environment (development, test, staging, production). It loads .json, .yaml, or .js files from a dedicated config/ directory and merges them based on NODE_ENV.
// config/default.json
{
"db": {
"host": "localhost",
"port": 5432
},
"apiTimeout": 5000
}
// config/production.json
{
"db": {
"host": "prod-db.example.com"
}
}
// In code
const config = require('config');
console.log(config.get('db.host')); // 'prod-db.example.com' in production
dotenv, by contrast, embraces flat key-value pairs stored in a .env file. It simply parses the file and injects variables into process.env. There’s no hierarchy, merging logic, or built-in support for multiple environments beyond what you layer yourself.
# .env
DB_HOST=localhost
DB_PORT=5432
API_TIMEOUT=5000
// In code
require('dotenv').config();
console.log(process.env.DB_HOST); // 'localhost'
This difference is critical: config gives you structure out of the box; dotenv gives you simplicity and leaves structure up to you.
Sometimes you need to compose values — for example, building a URL from a host and port. Neither config nor basic dotenv supports this natively.
dotenv-expand solves this by enabling variable interpolation within .env files, using syntax familiar from shell scripts.
# .env
HOST=localhost
PORT=3000
BASE_URL=http://${HOST}:${PORT}
// In code
const dotenv = require('dotenv');
const dotenvExpand = require('dotenv-expand');
const myEnv = dotenv.config();
dotenvExpand.expand(myEnv);
console.log(process.env.BASE_URL); // 'http://localhost:3000'
Note that dotenv-expand is not a standalone package — it’s designed to wrap the result of dotenv.config(). You always use it with dotenv.
config doesn’t support this kind of string interpolation directly. If you need computed values, you’d typically define them in a .js config file:
// config/default.js
const host = process.env.HOST || 'localhost';
const port = process.env.PORT || 3000;
module.exports = {
baseUrl: `http://${host}:${port}`
};
So if your configuration logic lives in .env files and requires composition, dotenv + dotenv-expand is your path. If you’re already using JavaScript-based config files, config handles this naturally.
A missing environment variable can cause subtle bugs or crashes in production. Basic dotenv offers no safeguards — if a variable is missing, process.env.MY_VAR is just undefined.
dotenv-safe addresses this by allowing you to declare required variables via an .env.example (or custom) file. If any required variable is missing from your actual .env, it throws an error at startup.
# .env.example (defines required vars)
DB_HOST=
API_KEY=
# .env (must include all keys from .env.example)
DB_HOST=prod.example.com
API_KEY=secret123
// In code
require('dotenv-safe').config();
// Throws if .env is missing DB_HOST or API_KEY
config has a similar safety net via its util.validateConfig() method (though less commonly used), but more often, developers rely on runtime checks like config.has('required.key') or schema validation libraries (e.g., Joi) layered on top.
Neither dotenv nor dotenv-expand includes validation — that’s why dotenv-safe exists as a drop-in replacement for dotenv when safety matters.
config expects a dedicated config/ directory with multiple files. This works well in traditional server deployments where you can manage filesystem layout, but it’s awkward in containerized or serverless environments where config is often injected via environment variables at runtime.
dotenv and its variants assume a single .env file (plus optional .env.local, etc.). This aligns better with 12-factor app principles, where config is strictly separated from code and passed via environment variables. Many CI/CD systems and platforms (like Vercel, Netlify, Heroku) expect this model.
If your team deploys via Docker or Kubernetes and injects secrets as env vars, dotenv may feel redundant — you might skip it entirely and read process.env directly. But during local development, .env files are invaluable for replicating production-like settings without hardcoding secrets.
It’s common to combine these tools rather than pick just one:
dotenv (or dotenv-safe) to load .env into process.env during local development.config to build structured app settings from those environment variables.Example:
// Load .env safely
require('dotenv-safe').config({
allowEmptyValues: true,
example: '.env.example'
});
// Then use config to organize settings
const appConfig = require('config');
// config/default.json can reference process.env values
And if you need variable expansion in .env, just add dotenv-expand:
const dotenv = require('dotenv');
const dotenvExpand = require('dotenv-expand');
const myEnv = dotenv.config();
dotenvExpand.expand(myEnv);
In fact, dotenv-safe and dotenv-expand are complementary, not competing — you can even use both:
const dotenvSafe = require('dotenv-safe');
const dotenvExpand = require('dotenv-expand');
const myEnv = dotenvSafe.config();
dotenvExpand.expand(myEnv);
This gives you validation and interpolation.
All four packages are Node.js-only. They rely on filesystem access (fs.readFileSync) and won’t work in browser environments. For frontend apps, you typically inline environment variables at build time (e.g., via Webpack’s DefinePlugin or Vite’s import.meta.env).
Also, none provide type safety out of the box. You’ll still need TypeScript interfaces or runtime validation (e.g., Zod, Joi) to ensure config shapes are correct.
| Package | Primary Use Case | Hierarchical? | Validates Required Vars? | Supports Interpolation? | Standalone? |
|---|---|---|---|---|---|
config | Structured, multi-env config files | ✅ | ❌ (manual) | ❌ | ✅ |
dotenv | Simple .env → process.env | ❌ | ❌ | ❌ | ✅ |
dotenv-expand | Add variable interpolation to dotenv | ❌ | ❌ | ✅ | ❌ (needs dotenv) |
dotenv-safe | Safe dotenv with required var checking | ❌ | ✅ | ❌ | ✅ |
config. It scales well as your environment matrix grows.dotenv-safe + dotenv-expand during local dev, but read process.env directly in production.dotenv in production code without validation — missing vars cause hard-to-debug failures.dotenv-safe to load env vars and config to structure them.The right choice isn’t about which package is “best” — it’s about matching the tool to your team’s deployment strategy, config complexity, and tolerance for runtime errors.
Choose config when you need a structured, hierarchical configuration system that supports multiple environments (development, staging, production) through dedicated config files. It’s ideal for complex applications where settings are organized in nested objects and you want automatic merging based on NODE_ENV. However, avoid it if your deployment model relies solely on injected environment variables without filesystem access, such as in some serverless or containerized setups.
Choose dotenv when you want a minimal, straightforward way to load environment variables from a .env file into process.env for local development. It’s perfect for simple applications or when you plan to manage configuration externally in production (e.g., via platform-provided env vars). Avoid it in production-critical paths without additional validation, as it provides no safeguards for missing or malformed variables.
Choose dotenv-expand when you’re already using dotenv and need to compose environment variables within your .env file using shell-like interpolation (e.g., DATABASE_URL=postgresql://${DB_HOST}:${DB_PORT}/mydb). Remember that it’s not a standalone package — it must be used alongside dotenv or dotenv-safe. Don’t use it if your configuration logic is better handled in JavaScript or if you don’t need variable composition.
Choose dotenv-safe when you need the simplicity of dotenv but require runtime validation to ensure all necessary environment variables are defined. It’s especially valuable in team environments or CI pipelines where missing configuration should fail fast rather than cause subtle bugs. Use it as a drop-in replacement for dotenv when safety is a priority, and consider pairing it with dotenv-expand if you also need variable interpolation.
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