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 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 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-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.
If you have found dotenv-expand useful, consider checking out dotenvx for encrypting your
.envfiles. Thank you for using dotenv. 🙏
Dotenv-expand is a zero-dependency module that adds variable expansion, command substitution, and encrypted value support on top of dotenv.
Install it alongside dotenv.
npm install dotenv dotenv-expand --save
Create a .env file in the root of your project:
# .env
USERNAME="dotenv"
DATABASE_URL="postgres://${USERNAME}@localhost/my_database"
As early as possible in your application, configure dotenv and expand its result:
// index.js
const dotenv = require('dotenv')
const dotenvExpand = require('dotenv-expand')
dotenvExpand.expand(dotenv.config())
console.log(process.env.DATABASE_URL)
$ node index.js
postgres://dotenv@localhost/my_database
That's it. process.env now contains the expanded values from your .env file.
Consider using dotenvx instead. It includes expansion, command substitution, and encrypted
.envsupport across languages and platforms:dotenvx run -- node your_script.js
Use Node's --require (-r) option to load and expand .env before your application starts:
node -r dotenv-expand/config your_script.js
The preload entry is self-contained; you do not need to separately install dotenv.
Configuration options can be passed as command-line arguments:
node -r dotenv-expand/config your_script.js dotenv_config_path=/custom/path/to/.env
Or as environment variables:
DOTENV_CONFIG_ENCODING=latin1 node -r dotenv-expand/config your_script.js
Command-line arguments take precedence over environment variables.
pnpm add dotenv dotenv-expand
Use $(command) to replace an expression with the command's output:
NODE_VERSION=$(node --version)
Only use command substitution with .env files you trust. Commands run with the permissions of the current process.
Use dotenvx to encrypt your .env file. dotenv-expand automatically decrypts encrypted: values when DOTENV_PRIVATE_KEY is set:
DOTENV_PRIVATE_KEY="<private key>" node -r dotenv-expand/config your_script.js
Keep the private key separate from the encrypted .env file—for example, in your cloud platform's secrets manager. Read the dotenvx whitepaper for more details.
Use processEnv to write expanded values to an object other than process.env:
const dotenvExpand = require('dotenv-expand')
const myEnv = {}
dotenvExpand.expand({
processEnv: myEnv,
parsed: {
HELLO: 'World'
}
})
console.log(myEnv.HELLO) // World
console.log(process.env.HELLO) // undefined
dotenv-expand supports braced and unbraced expansion, default values, and alternate values:
BASIC="basic"
BRACED=${BASIC}
UNBRACED=$BASIC
DEFAULT=${MISSING:-default}
ALTERNATE=${BASIC:+alternate}
See the complete interpolation rules.
Use $(command) in your .env file. dotenv-expand runs the command and replaces the expression with its output. See the Command Substitution example under Advanced.
Use dotenvx to encrypt your .env file. dotenv-expand automatically decrypts encrypted: values when DOTENV_PRIVATE_KEY is set.
DOTENV_PRIVATE_KEY="<private key>" node -r dotenv-expand/config your_script.js
Keep the private key separate from the encrypted .env file—for example, in your cloud platform's secrets manager. Read the dotenvx whitepaper for more details.
Yes, as long as the corresponding DOTENV_PRIVATE_KEY is stored separately and never committed with it. Use dotenvx to create and manage the encrypted file.
As of v12.0.0 dotenv-expand no longer expands process.env.
If you need this ability, use dotenvx and ship an encrypted .env file with your code, allowing safe expansion at runtime.
Use dotenvx, as dotenv-expand does not support this.
dotenv-expand is a separate module (without knowledge of the loading of process.env and the .env file) and so cannot reliably know what to override.
expand expands, evaluates, and decrypts values in a dotenv result:
const dotenvExpand = require('dotenv-expand')
const result = dotenvExpand.expand({
parsed: {
BASIC: 'basic',
BASIC_EXPAND: '${BASIC}',
BASIC_EXPAND_SIMPLE: '$BASIC'
}
})
console.log(result.parsed)
processEnvDefault: process.env
Specify an object to read existing values from and write expanded values to.
parsedThe parsed key-value object to expand. This is normally the result of dotenv.config().
See CONTRIBUTING.md.
See CHANGELOG.md.