config vs convict vs dotenv vs nconf
Architecting Configuration Management in Node.js Applications
configconvictdotenvnconfSimilar Packages:

Architecting Configuration Management in Node.js Applications

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.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
config06,428158 kB1114 days agoMIT
convict02,37541.9 kB725 months agoApache-2.0
dotenv020,519103 kB24 months agoBSD-2-Clause
nconf03,860162 kB113a year agoMIT

Architecting Configuration Management in Node.js Applications

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.

πŸ“₯ Loading Strategy: Simple Injection vs. Hierarchical Merging

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');

πŸ›‘οΈ Validation: Fail Fast vs. Runtime Errors

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

πŸ”„ Environment Overrides: Convention vs. Configuration

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
  }
});

πŸ—‚οΈ Data Formats and Structure

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');

🚫 Deprecation and Maintenance Status

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.

🌐 Real-World Scenarios

Scenario 1: Quick Local Development

You are spinning up a microservice and just need to swap database URLs between your laptop and the cloud.

  • βœ… Best choice: dotenv
  • Why? It's the standard. Every developer has it installed globally or as a dev dependency. It requires zero setup.
require('dotenv').config();
const dbUrl = process.env.DATABASE_URL;

Scenario 2: Enterprise App with Strict Environments

You have a large app with default, staging, production, and local configs. You want to ensure no one accidentally commits secrets to production.json.

  • βœ… Best choice: config
  • Why? The local-*.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');

Scenario 3: Critical Service with Complex Config

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.

  • βœ… Best choice: convict (often paired with dotenv)
  • Why? The 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...');

Scenario 4: CLI Tool with Dynamic Flags

You are building a command-line tool where users can pass --port or --verbose to override settings in a config file.

  • βœ… Best choice: nconf
  • Why? Its built-in support for argv (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');

πŸ“Š Summary: Key Differences

Featuredotenvconfigconvictnconf
Primary GoalLoad .env to process.envHierarchical file mergingSchema validationFlexible source merging
Validation❌ None❌ Noneβœ… Strict (Types, Required)❌ None
Hierarchy❌ Flat (Manual merge)βœ… Automatic (File-based)⚠️ Manual (Schema-based)βœ… Manual (Code-based)
FormatsKey-ValueJSON, JS, YAML, TOMLAny (via loader)JSON, Env, Args
Best ForSimple apps, Dev setupLarge apps, Team standardsCritical services, SafetyCLI tools, Legacy systems

πŸ’‘ The Big Picture

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.

How to Choose: config vs convict vs dotenv vs nconf

  • config:

    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.

  • convict:

    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.

  • dotenv:

    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.

  • nconf:

    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.

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/parser, 'config/util/defer', 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