config, convict, dotenv, and nconf are the primary solutions for managing environment variables and application settings in Node.js. dotenv is a lightweight utility that loads .env files into process.env, serving as the foundation for many other tools. config provides an opinionated, hierarchical system that loads JSON, JS, or YAML files based on the NODE_ENV, enforcing a strict directory structure. convict focuses on defining a strict schema with types and default values, validating configuration at startup to fail fast on errors. nconf offers a flexible, hierarchical key-value store that can merge data from command-line arguments, environment variables, and files, though it requires more manual setup than the others.
Managing configuration is one of the first architectural decisions you make in a Node.js project. Getting it wrong leads to hard-coded secrets, difficult deployments, and "it works on my machine" bugs. The ecosystem offers four distinct approaches: dotenv for simple loading, config for opinionated hierarchies, convict for strict validation, and nconf for flexible merging. Let's break down how they solve real-world problems.
The core difference lies in how these tools bring data into your application. Do you just want to fill process.env, or do you need a smart object that merges defaults with environment-specific overrides?
dotenv is the simplest. It reads a .env file and dumps everything into process.env. It doesn't merge or validate; it just sets strings.
// dotenv: Simple loading
require('dotenv').config();
// Accessing values
const dbHost = process.env.DB_HOST;
config uses a strict folder structure (config/) to automatically merge files. It loads default.json first, then overlays production.json if NODE_ENV is set to production.
// config: Hierarchical loading
// 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 in production
nconf requires you to manually define the hierarchy. You tell it exactly which source wins (e.g., command line args > env vars > file).
// nconf: Manual hierarchy setup
const nconf = require('nconf');
nconf.argv().env().file({ file: 'config.json' });
const dbHost = nconf.get('db:host'); // Uses colon notation by default
convict doesn't load files itself by default; it defines a schema and loads values passed to it (often from dotenv or nconf). It focuses on the shape of the data rather than the source.
// convict: Schema-based loading
const convict = require('convict');
const config = convict({
db: {
host: { doc: 'Database host', format: String, default: 'localhost' }
}
});
// You must load values manually or via plugins
config.load({ db: { host: process.env.DB_HOST } });
const dbHost = config.get('db.host');
Nothing kills a deployment faster than discovering a missing API key halfway through startup. Some libraries let you define exactly what your config should look like before the app starts.
convict shines here. You define types, default values, and whether a field is required. If the config doesn't match, the app throws an error immediately.
// convict: Strict validation
const config = convict({
port: {
doc: 'Port to listen on',
format: 'port',
default: 3000,
env: 'PORT'
},
apiKey: {
doc: 'Secret API Key',
format: String,
default: null,
arg: 'api-key',
sensitive: true
}
});
config.validate({ allowed: 'strict' }); // Throws if apiKey is missing or port is invalid
config has no built-in schema validation. If you request a key that doesn't exist, it returns undefined. You have to write your own checks or rely on the file structure being correct.
// config: No built-in validation
const val = config.get('database.port');
if (val === undefined) {
throw new Error('Missing database port');
}
dotenv provides zero validation. It treats everything as a string. If you expect a number and get "banana", dotenv won't complain.
// dotenv: No validation
// .env: PORT=banana
require('dotenv').config();
// process.env.PORT is "banana". Your app might crash later when trying to bind to this port.
nconf also lacks built-in schema validation. It acts as a storage layer. You can retrieve values, but ensuring they are the right type is up to your code.
// nconf: No built-in validation
const port = nconf.get('port');
// You must manually check if 'port' is a number
How do you handle differences between local development, staging, and production? Do you rely on file names, environment variables, or command-line flags?
config relies entirely on file naming conventions. You create local.json for your machine, which is gitignored, and production.json for the server. It handles the switching automatically based on NODE_ENV.
# config: File-based override
# Set NODE_ENV=production
# Automatically loads config/production.json overriding config/default.json
node server.js
nconf lets you mix sources dynamically. You can pass a flag via the command line to override a file setting, which is great for CLI tools or temporary debug modes.
// nconf: Multi-source override
// Run: node server.js --port 9000
// The --port arg overrides the value in config.json or process.env
nconf.argv().env().file({ file: 'config.json' });
const port = nconf.get('port'); // Returns 9000 from CLI arg
dotenv handles overrides by letting you load multiple files or relying on the shell. Usually, developers use dotenv-cli to load .env.production instead of .env.
# dotenv: Shell-based override
# Manually specify which file to load
dotenv -e .env.production -- node server.js
convict handles overrides by mapping specific environment variables or command-line arguments directly to schema fields. You define the mapping in the schema itself.
// convict: Schema-mapped override
const conf = convict({
env: {
doc: 'Environment',
format: String,
default: 'development',
env: 'NODE_ENV' // Automatically maps NODE_ENV to this field
}
});
Your configuration might be simple key-value pairs, or it might be deeply nested JSON objects. The tools handle these differently.
config supports JSON, JS, CoffeeScript, YAML, and TOML out of the box (via optional dependencies). It preserves nested structures naturally.
// config: Nested structure support
// config/default.yaml
// database:
// host: localhost
// credentials:
// user: admin
const dbUser = config.get('database.credentials.user');
dotenv only supports flat key-value pairs in the .env file. If you need nested data, you have to parse JSON strings manually.
# dotenv: Flat structure only
# .env
DB_HOST=localhost
# For nested data, you must stringify it
DB_CREDENTIALS='{"user":"admin","pass":"123"}'
// dotenv: Manual parsing required
const creds = JSON.parse(process.env.DB_CREDENTIALS);
nconf stores data as a hierarchical key-value store but accesses it via colon-separated strings by default. It can read JSON files easily.
// nconf: Colon notation for nested keys
// config.json: { "database": { "host": "localhost" } }
nconf.file({ file: 'config.json' });
const host = nconf.get('database:host');
convict works best with nested objects defined in the schema. It accesses them using dot notation, similar to standard JavaScript objects.
// convict: Dot notation for nested keys
const config = convict({
database: {
host: { format: String, default: 'localhost' }
}
});
const host = config.get('database.host');
Before choosing, it is critical to note the maintenance status of these libraries.
nconf: While still widely used, nconf has seen very infrequent updates in recent years and has open issues regarding modern Node.js versions. It is not officially deprecated on npm, but its development pace is slow. For new projects requiring complex merging, consider if config or a custom solution with convict might be more future-proof.config, convict, and dotenv are actively maintained and widely adopted in the industry.You are spinning up a microservice and just need to swap database URLs between your laptop and the cloud.
dotenvrequire('dotenv').config();
const dbUrl = process.env.DATABASE_URL;
You have a large app with default, staging, production, and local configs. You want to ensure no one accidentally commits secrets to production.json.
configlocal-*.json pattern is gitignored by convention, keeping secrets safe while allowing easy overrides.// config/production.json (committed)
// config/local-production.json (gitignored, overrides production)
const dbPass = config.get('db.password');
Your service will crash if a specific feature flag is missing or if a port is out of range. You need to guarantee config integrity at boot.
convict (often paired with dotenv)validate({ allowed: 'strict' }) call ensures the app refuses to start if the environment is misconfigured.config.validate({ allowed: 'strict' });
console.log('Config is valid, starting server...');
You are building a command-line tool where users can pass --port or --verbose to override settings in a config file.
nconfargv (command line arguments) taking precedence over files is exactly what CLI tools need.nconf.argv().env().file({ file: 'settings.json' });
const isVerbose = nconf.get('verbose');
| Feature | dotenv | config | convict | nconf |
|---|---|---|---|---|
| Primary Goal | Load .env to process.env | Hierarchical file merging | Schema validation | Flexible source merging |
| Validation | β None | β None | β Strict (Types, Required) | β None |
| Hierarchy | β Flat (Manual merge) | β Automatic (File-based) | β οΈ Manual (Schema-based) | β Manual (Code-based) |
| Formats | Key-Value | JSON, JS, YAML, TOML | Any (via loader) | JSON, Env, Args |
| Best For | Simple apps, Dev setup | Large apps, Team standards | Critical services, Safety | CLI tools, Legacy systems |
dotenv is the universal adapter π§±. It's almost always present, even if you use other tools on top of it. Use it for simple projects or as the base layer for loading secrets.
config is the opinionated organizer ποΈ. It forces your team to follow a structure that scales well as the project grows. It removes the need to write merge logic but demands you follow its file naming rules.
convict is the safety inspector π‘οΈ. It doesn't care where the data comes from, only that it is correct. Pair it with dotenv or config to add a critical layer of reliability to your startup sequence.
nconf is the flexible integrator π. It handles complex merging scenarios (CLI > Env > File) better than the others but requires more code to set up. Use it for tools or specific cases where source priority is dynamic.
Final Thought: For most modern web applications, a combination of dotenv (for loading secrets) and convict (for validation) offers the best balance of simplicity and safety. If your team struggles with managing multiple environment files, switch to config to enforce order.
Choose config if you want a 'batteries-included' solution that automatically handles hierarchical overrides (e.g., default.json vs production.json) without writing custom merge logic. It is ideal for teams that prefer convention over configuration and want to separate sensitive data from code using the local-*.json pattern. Avoid it if you dislike rigid directory structures or need to support non-JSON formats like YAML without extra plugins.
Choose convict if type safety and strict validation are your top priorities, ensuring your app crashes immediately if a required config is missing or malformed. It is perfect for complex applications where configuration shapes need to be documented and enforced via a schema definition. Use it alongside dotenv or config to add a robust validation layer before the application starts processing requests.
Choose dotenv if you need a zero-dependency way to load local .env files into process.env during development or in simple containerized setups. It is the standard baseline for most projects but lacks validation or hierarchical merging logic on its own. Use it when your team prefers managing secrets directly in the environment or via orchestration tools like Kubernetes ConfigMaps.
Choose nconf if you need maximum flexibility to merge configuration sources in a specific order, such as prioritizing command-line arguments over environment variables, which then override file settings. It is suitable for CLI tools or legacy systems where configuration sources vary wildly between deployments. Be aware that it requires more boilerplate code to set up the hierarchy compared to config or convict.
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