config, dotenv, nconf, and rc are the four primary solutions for managing application settings in Node.js, but they solve different layers of the configuration problem. dotenv is a lightweight utility that simply loads environment variables from a .env file into process.env, acting as a bridge between the OS and your app. config provides a robust, hierarchical system that merges JSON, YAML, or JS files based on the NODE_ENV, enforcing a strict separation between code and config without relying solely on environment variables. nconf offers a flexible, hierarchical key-value store that can mix sources like files, memory, and environment variables, often used in older or highly dynamic setups. rc is a minimalistic parser designed specifically for command-line tools to read runtime configurations from flags, env vars, and standard config files (like .myapprc) simultaneously.
Managing configuration is one of the first architectural decisions you make in a Node.js project. While it seems simple β just reading a few values β the approach you choose dictates how your app handles deployments, secrets, multi-environment setups, and CLI interactions. The four main players (config, dotenv, nconf, rc) are not interchangeable; they target different stages of the application lifecycle and different complexity levels.
Let's break down how they work, where they shine, and where they fall short.
The fundamental difference lies in where they look for data and how they merge it.
dotenv is the simplest. It does one thing: reads a .env file and dumps the key-value pairs into process.env. It doesn't understand hierarchy, types, or file merging. It assumes your app reads directly from process.env.
// dotenv: Simple injection into process.env
require('dotenv').config();
// Usage elsewhere
const dbHost = process.env.DB_HOST;
// If DB_HOST isn't in .env or OS, this is undefined.
config ignores process.env by default (unless explicitly mapped). Instead, it looks into a config/ folder and merges files based on the NODE_ENV. It enforces a strict hierarchy: default.json is loaded first, then production.json overwrites it.
// config: Hierarchical file merging
// config/default.json: { "db": { "host": "localhost" } }
// config/production.json: { "db": { "host": "prod-db.example.com" } }
const config = require('config');
const dbHost = config.get('db.host');
// Automatically resolves to 'prod-db.example.com' if NODE_ENV=production
nconf creates a hierarchical store where you manually define the order of precedence (e.g., command line args > env vars > file). It acts as a key-value database for your settings.
// nconf: Manual hierarchy definition
const nconf = require('nconf');
nconf.argv().env().file({ file: 'config.json' });
const dbHost = nconf.get('db:host');
// Checks CLI args first, then ENV, then file.
rc is built for CLIs. It checks command-line arguments, environment variables, and a specific RC file (like .myapprc) in a predefined standard order. It returns a plain object.
// rc: Standard CLI config resolution
const rc = require('rc')('myapp', { port: 3000 });
// Usage: Reads from --port flag, MYAPP_PORT env, or .myapprc file
const port = rc.port;
Real-world apps need different settings for development, staging, and production. How each package handles this defines its utility.
config excels here. It automatically detects NODE_ENV and loads corresponding files (development.json, production.json). You don't write if/else logic; the library handles the merge.
// config: Automatic environment switching
// No code changes needed. Just set NODE_ENV=staging
// Loads: default.json -> staging.json -> custom-environment-variables.json
const mailServer = config.get('mail.host');
dotenv has no built-in hierarchy. You must manage multiple files yourself (e.g., .env.development, .env.production) and load the correct one manually or via tooling like dotenv-cli.
// dotenv: Manual file selection
require('dotenv').config({ path: `.env.${process.env.NODE_ENV}` });
const mailServer = process.env.MAIL_HOST;
// You must ensure the correct file exists and is loaded.
nconf allows dynamic overrides at runtime. You can change a value in memory, and it persists for the app's life. This is powerful but can lead to "magic" behavior where config changes unexpectedly.
// nconf: Runtime overrides
nconf.set('feature:enabled', true);
// Changes config in memory immediately
rc focuses on user-specific overrides. It looks for global config (/etc/myapprc), user config (~/.myapprc), and local config (.myapprc), allowing users to customize CLI behavior without changing code.
// rc: User-level hierarchy
// Merges defaults -> global -> user -> local -> env -> argv
const rc = require('rc')('myapp', { timeout: 5000 });
Handling API keys and passwords is critical. The approach varies significantly.
dotenv is the most common for secrets because it keeps them out of code files. However, it relies entirely on the OS environment. If you accidentally commit .env, secrets are leaked.
// dotenv: Secret management
// .env file (gitignored)
DB_PASSWORD=supersecret
// App
require('dotenv').config();
const pass = process.env.DB_PASSWORD;
config discourages putting secrets in JSON files. Instead, it uses a special file custom-environment-variables.json to map env vars to config keys. This keeps your main config files safe to commit while pulling secrets from the environment.
// config: Secure mapping
// config/custom-environment-variables.json
// { "db": { "password": "DB_PASSWORD" } }
// App
const pass = config.get('db.password');
// Pulls from process.env.DB_PASSWORD automatically
nconf treats env vars as just another layer. You must ensure the env layer is higher priority than the file layer to prevent files from overriding secrets.
// nconf: Priority-based secrets
nconf.env(['DB_PASSWORD']).file('config.json');
// Env vars will override file values if priorities are set right
rc exposes secrets via environment variables prefixed with the app name. It's less explicit about security mapping compared to config.
// rc: Prefix-based secrets
// Set MYAPP_DB_PASSWORD in env
const rc = require('rc')('myapp');
const pass = rc.db_password;
How easy is it to catch errors when config is missing or malformed?
config throws clear errors if a requested key is missing (Error: Configuration property "db.host" is not defined). It also supports custom modules for validation logic, making it robust for large teams.
// config: Built-in validation
try {
const port = config.get('server.port');
} catch (e) {
// Throws explicit error if key is missing
console.error(e.message);
}
dotenv offers no validation. If a variable is missing, you get undefined, which might crash your app later in a hard-to-debug place. You must add your own checks (e.g., using zod or manual if statements).
// dotenv: No validation
const port = process.env.PORT;
if (!port) throw new Error('PORT is required'); // Manual check needed
nconf returns null or undefined for missing keys. It provides a .required() method in some versions, but it's less standardized than config's approach.
// nconf: Basic checks
const port = nconf.get('port');
if (!port) { /* Handle missing */ }
rc returns defaults if nothing is found. It's designed to be permissive, assuming CLI tools should run with sensible defaults even if config is missing.
// rc: Defaults focused
const rc = require('rc')('myapp', { port: 3000 });
// If no config found, port is 3000. No error thrown.
| Feature | config | dotenv | nconf | rc |
|---|---|---|---|---|
| Primary Use | Complex App Config | Simple Env Injection | Legacy/Dynamic Stores | CLI Tools |
| Hierarchy | Automatic (File-based) | None (Manual) | Manual (Chainable) | Standard (RC spec) |
| Env Overrides | Explicit Mapping | Direct Injection | Layer Priority | Prefix Matching |
| Validation | Strong (Throws errors) | None | Weak | None (Defaults) |
| File Formats | JSON, JS, YAML, TOML | Key-Value (.env) | JSON, Ini | JSON, Ini, YAML |
dotenv is the utility belt π§°. It's small, everywhere, and solves the "how do I get .env into my app" problem. Use it for simple apps, scripts, or alongside other tools to inject secrets. It is not a full configuration system.
config is the enterprise vault π¦. It provides structure, safety, and scalability. If your team grows, or if you have complex nested settings that change per environment, config pays for itself in reduced bugs and clearer architecture. It enforces discipline.
nconf is the legacy Swiss Army Knife πͺ. It can do almost anything but requires more setup and care. Unless you are maintaining an older system that depends on it, modern alternatives like config usually offer a better developer experience.
rc is the CLI specialist ποΈ. If you are building a tool that developers install globally and configure via flags and dotfiles, rc is the standard. Don't use it for a standard web server backend.
Final Thought: For most modern Node.js backends, the winning combination is config for application structure + dotenv (or platform env vars) for secret injection. This gives you the hierarchy and validation of config while keeping secrets out of your files.
Choose config for large-scale enterprise applications where configuration complexity grows over time. It is ideal when you need strict hierarchy (e.g., default.json overridden by production.json), type safety via custom modules, and the ability to change configs without redeploying code. Avoid it if you strictly adhere to the '12-Factor App' methodology that demands all config come exclusively from environment variables.
Choose nconf only if you are maintaining a legacy codebase that already relies on its specific hierarchical store pattern or if you need a very specific mix of asynchronous configuration sources. For new projects, avoid nconf in favor of config or dotenv due to its slower update cadence and more complex API surface for modern use cases.
Choose dotenv for simple projects, local development setups, or serverless functions where you just need to inject secrets into process.env. It is the standard choice for bootstrapping apps quickly and works well with platforms like Vercel or Docker that manage env vars externally. Do not use it as your sole configuration strategy for complex apps requiring nested settings or runtime hierarchy merging.
Choose rc if you are building a Command Line Interface (CLI) tool where users need to override settings via command-line flags, environment variables, and local config files simultaneously. It is the industry standard for developer tools (like linters or bundlers) but is generally unsuitable for standard web server applications where configuration is static at startup.
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/parser, 'config/util/defer', 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