config vs dotenv vs dotenv-expand vs dotenv-safe
Environment Configuration Management in Node.js
configdotenvdotenv-expanddotenv-safeSimilar Packages:

Environment Configuration Management in Node.js

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.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
config06,424207 kB13a month agoMIT
dotenv020,35293.3 kB102 months agoBSD-2-Clause
dotenv-expand01,05219.5 kB37 months agoBSD-2-Clause
dotenv-safe077110.4 kB42 years agoMIT

Environment Configuration in Node.js: config vs dotenv vs dotenv-expand vs dotenv-safe

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.

🧩 Core Philosophy: File-Based Hierarchy vs Flat .env Simplicity

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.

🔍 Variable Expansion: Injecting One Value into Another

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.

🛡️ Safety and Validation: Preventing Silent Failures

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.

📁 File Organization and Deployment Workflow

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.

🔗 Composability: How These Packages Work Together

It’s common to combine these tools rather than pick just one:

  • Use dotenv (or dotenv-safe) to load .env into process.env during local development.
  • Use 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.

🚫 What None of These Do Well

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.

📊 Summary Table

PackagePrimary Use CaseHierarchical?Validates Required Vars?Supports Interpolation?Standalone?
configStructured, multi-env config files❌ (manual)
dotenvSimple .env → process.env
dotenv-expandAdd variable interpolation to dotenv❌ (needs dotenv)
dotenv-safeSafe dotenv with required var checking

💡 Practical Recommendations

  • For enterprise backends with complex config: Start with config. It scales well as your environment matrix grows.
  • For modern cloud-native apps (serverless, containers): Prefer dotenv-safe + dotenv-expand during local dev, but read process.env directly in production.
  • Never use plain dotenv in production code without validation — missing vars cause hard-to-debug failures.
  • Combine tools when needed: There’s no rule against using 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.

How to Choose: config vs dotenv vs dotenv-expand vs dotenv-safe

  • config:

    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.

  • dotenv:

    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.

  • dotenv-expand:

    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.

  • dotenv-safe:

    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.

README for config

Configure your Node.js Applications

npm package Downloads Issues

Release Notes

Introduction

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.

Project Guidelines

  • Simple - Get started fast
  • Powerful - For multi-node enterprise deployment
  • Flexible - Supporting multiple config file formats
  • Lightweight - Small file and memory footprint
  • Predictable - Well tested foundation for module and app developers

Quick Start

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.

TypeScript

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.

Articles

Further Information

If you still don't see what you are looking for, here are some more resources to check:

Contributors

lorenwestjdmarshallmarkstosi­Moseselliotttfmdkitzman
jfelegeleachi­M2kjosxenyoleosuncinarthanzel
leonardovillelajeremy-daley-krsimon-scherzingerBadger­Badger­Badger­Badgernsaboviccunneen
Osterjourth507tiny-rac00neheikesfgheorgheroncli
superovenairdrummingfoolwmertensXadilla­Xinsidedsbert

License

May be freely distributed under the MIT license.

Copyright (c) 2010-2026 Loren West and other contributors