Definition: Configuration Management in Node.js
Configuration management in Node.js is the practice of separating environment-specific values (ports, API URLs, feature flags, credentials) from your application code so they can change across environments without code changes.
The spectrum ranges from zero-dependency raw process.env access to full configuration frameworks with schema validation, type coercion, and cascading overrides:
raw process.env
Zero deps
dotenv
Load .env files
dotenv-flow
Cascading envs
node-config
JSON/YAML files
convict
Schema + validation
The Pareto rule: dotenv covers 80–90% of Node.js project configuration needs. The other tools solve specific problems — understand when you actually need them before adding complexity.
What: 5 Configuration Approaches Explained
Raw process.env
No packages, just system environment variables
process.env is a built-in Node.js object (available without any npm install) that holds all environment variables set by the operating system or shell. Variables set before running node are accessible immediately.
// No imports needed — process.env is always available
const port = process.env.PORT || '3000';
const databaseUrl = process.env.DATABASE_URL;
const nodeEnv = process.env.NODE_ENV || 'development';
// Set before running node:
// PORT=8080 DATABASE_URL=postgresql://... node server.js
// Or export in shell:
// export PORT=8080
// node server.js
console.log('Listening on port', port);
console.log('Environment:', nodeEnv);
// All values from process.env are STRINGS — no type coercion
const maxConnections = parseInt(process.env.MAX_CONNECTIONS || '10', 10);
const debugMode = process.env.DEBUG === 'true'; // compare as stringPros
- No npm dependencies
- Works identically in all environments
- Platform env vars just work (no config needed)
- Simple to understand and debug
Cons
- Must set env vars in shell manually (no .env file)
- No validation — missing vars fail silently
- All values are strings — manual type casting needed
- Tedious to manage many variables across team
dotenv
Load .env files into process.env — the industry standard
dotenv reads a .env file and merges its values into process.env. It is the default choice for local development in virtually every Node.js and Next.js project.
# Install
npm install dotenv
# .env file (never commit this)
PORT=3000
DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
STRIPE_SECRET_KEY=sk-test-YOUR_KEY
JWT_SECRET=my-super-secret-jwt-signing-key-32-chars// server.js — load dotenv FIRST, before any other imports
require('dotenv').config();
// Or in ES Modules:
// import 'dotenv/config';
const express = require('express');
const app = express();
// Now process.env has all values from .env merged in
const PORT = process.env.PORT || '3000';
const DATABASE_URL = process.env.DATABASE_URL;
app.listen(parseInt(PORT), () => {
console.log(`Server running on port ${PORT}`);
});
// dotenv.config() options:
require('dotenv').config({
path: '.env.local', // custom file path
override: true, // override existing env vars
debug: true, // log what dotenv loads
});Important: dotenv does NOT override existing environment variables by default. If PORT is already set in the shell, the .env file value is ignored. This is intentional — platform env vars take precedence over .env files.
Pros
- Industry standard — every developer knows it
- Tiny package — no sub-dependencies
- Works with all Node.js frameworks
- Next.js has built-in dotenv support (no install needed)
Cons
- No built-in schema validation
- One .env file — no cascading per environment
- All values are strings — manual casting required
- No IDE autocomplete for env var names
dotenv-flow
Cascading .env files for multiple environments
dotenv-flow extends dotenv by loading multiple .env files in a defined priority order, merging them. Higher-priority files override lower-priority ones. This enables a base configuration with per-environment overrides.
npm install dotenv-flow
# File loading order (later files override earlier ones):
# .env — base, committed to git (non-secret defaults)
# .env.local — local overrides, NOT committed
# .env.development — dev-specific, can be committed
# .env.development.local — local dev overrides, NOT committed
# .env.test, .env.production (and their .local variants)# .env (committed — base defaults, no secrets)
PORT=3000
LOG_LEVEL=info
API_BASE_URL=https://api.myapp.com
FEATURE_NEW_DASHBOARD=false
# .env.development (committed — dev-safe overrides)
LOG_LEVEL=debug
API_BASE_URL=http://localhost:4000
FEATURE_NEW_DASHBOARD=true
# .env.development.local (NOT committed — local secrets)
DATABASE_URL=postgresql://postgres:localpassword@localhost:5432/myapp_dev
STRIPE_SECRET_KEY=sk-test-YOUR_KEY
# .env.production (committed — production non-secret config)
LOG_LEVEL=warn
API_BASE_URL=https://api.myapp.com// server.js
require('dotenv-flow').config();
// Automatically loads files based on NODE_ENV
// NODE_ENV=development loads:
// .env → .env.local → .env.development → .env.development.local
// NODE_ENV=production loads:
// .env → .env.local → .env.production → .env.production.local
const port = process.env.PORT;
const logLevel = process.env.LOG_LEVEL;
const featureFlag = process.env.FEATURE_NEW_DASHBOARD === 'true';
console.log(`Loaded config for NODE_ENV=${process.env.NODE_ENV}`);Pros
- Cascading files — clean separation per environment
- Can commit non-secret defaults (.env, .env.development)
- Drop-in replacement for dotenv
- Good for teams with multiple environments
Cons
- More files to manage — can get confusing
- Still no schema validation or type coercion
- Less popular than plain dotenv — onboarding friction
config npm package (node-config)
JSON/YAML config files with environment overrides
The config package (node-config) uses a config/ directory with JSON, YAML, or JS files. A default.json provides base values; environment-specific files override them. Values can also reference environment variables for secrets.
npm install config
# Directory structure:
# config/
# default.json — base config (committed)
# development.json — dev overrides (committed)
# production.json — prod overrides (committed)
# custom-environment-variables.json — maps env vars to config keys// config/default.json
{
"server": {
"port": 3000,
"host": "localhost"
},
"database": {
"pool": {
"min": 2,
"max": 10
},
"ssl": false
},
"features": {
"newDashboard": false,
"betaSignup": false
},
"logging": {
"level": "info",
"format": "json"
}
}// config/development.json — overrides default.json in development
{
"server": {
"port": 3001
},
"database": {
"pool": {
"min": 1,
"max": 3
}
},
"features": {
"newDashboard": true
},
"logging": {
"level": "debug"
}
}// config/custom-environment-variables.json
// Maps environment variables to config keys (for secrets)
{
"database": {
"url": "DATABASE_URL"
},
"stripe": {
"secretKey": "STRIPE_SECRET_KEY"
},
"jwt": {
"secret": "JWT_SECRET"
}
}// server.js — using node-config
const config = require('config');
const port = config.get('server.port'); // number, not string!
const dbUrl = config.get('database.url'); // from DATABASE_URL env var
const poolMax = config.get('database.pool.max');
const featureFlag = config.get('features.newDashboard'); // boolean
// config.get() throws if the key doesn't exist
// config.has() returns false instead of throwing
const stripeKey = config.has('stripe.secretKey')
? config.get('stripe.secretKey')
: null;
// NODE_ENV selects the override file:
// NODE_ENV=development → loads default.json + development.json
// NODE_ENV=production → loads default.json + production.jsonPros
- Rich config files — JSON, YAML, JS, TOML
- Nested config structure (server.port, db.pool.max)
- Non-secret config is committed — team always in sync
- config.get() throws for missing keys
- Mature — been around since 2011
Cons
- Heavier setup — multiple files to create
- No TypeScript types by default
- Unusual mental model compared to dotenv
- Risk of committing secrets in config files by mistake
convict
Schema-validated config with types, defaults, and documentation
convict (by Mozilla) wraps configuration in a strongly-typed schema. You define each key with its type, default value, environment variable name, documentation string, and allowed values. convict validates the entire configuration at startup and provides helpful error messages for misconfiguration.
npm install convict
npm install @types/convict # TypeScript support// config/index.ts
import convict from 'convict';
const config = convict({
env: {
doc: 'The application environment',
format: ['production', 'development', 'test'],
default: 'development',
env: 'NODE_ENV',
},
server: {
port: {
doc: 'The port to bind the server to',
format: 'port', // validates it's a valid port number
default: 3000,
env: 'PORT',
arg: 'port', // can also be set via --port CLI argument
},
host: {
doc: 'Server hostname',
format: String,
default: 'localhost',
env: 'HOST',
},
},
database: {
url: {
doc: 'PostgreSQL connection string',
format: String,
default: null, // null means required — will throw if not set
env: 'DATABASE_URL',
sensitive: true, // redacted in logs
},
pool: {
min: {
doc: 'Minimum database connections in pool',
format: 'int',
default: 2,
env: 'DB_POOL_MIN',
},
max: {
doc: 'Maximum database connections in pool',
format: 'int',
default: 10,
env: 'DB_POOL_MAX',
},
},
},
stripe: {
secretKey: {
doc: 'Stripe secret API key',
format: String,
default: null,
env: 'STRIPE_SECRET_KEY',
sensitive: true,
},
webhookSecret: {
doc: 'Stripe webhook signing secret',
format: String,
default: null,
env: 'STRIPE_WEBHOOK_SECRET',
sensitive: true,
},
},
features: {
newDashboard: {
doc: 'Enable the new dashboard UI',
format: Boolean,
default: false,
env: 'FEATURE_NEW_DASHBOARD',
},
},
});
// Load environment-specific config file if it exists
const env = config.get('env');
try {
config.loadFile(`./config/${env}.json`);
} catch {
// No env-specific file — that's fine
}
// Validate — throws with detailed error message if invalid
config.validate({ allowed: 'strict' });
export default config;// Usage in your application:
import config from './config';
const port = config.get('server.port'); // type: number (not string!)
const dbUrl = config.get('database.url'); // type: string
const poolMax = config.get('database.pool.max'); // type: number
const featureFlag = config.get('features.newDashboard'); // type: boolean
// config.toString() → prints all config values, REDACTING sensitive ones
console.log(config.toString());
// { server: { port: 3000, host: 'localhost' },
// database: { url: '[Sensitive]', pool: { min: 2, max: 10 } },
// stripe: { secretKey: '[Sensitive]', ... } }Pros
- Full type coercion — numbers stay numbers
- Schema validation at startup
- Built-in docs for every config key
- Sensitive values auto-redacted in logs
- Supports env vars, config files, and CLI args
Cons
- Most verbose — heavy schema setup
- TypeScript types are not fully inferred
- Overkill for small/medium projects
- Zod has largely replaced convict in TypeScript projects
When: Which Tool for Which Scenario
Simple scripts, cron jobs, CLI tools
Use raw process.env. Set vars in your shell or CI. No .env file needed. Zero dependencies, no setup. If the script needs 1–3 config values, this is the right choice.
Most Node.js and Next.js applications
Use dotenv (or dotenv is already built into Next.js). Add .env.local to .gitignore. Add Zod validation for type safety. This covers 90% of real-world projects. No need to reach for something heavier.
Teams managing multiple environments (dev/staging/prod)
Use dotenv-flow. Enables committing non-secret base config while keeping secrets local. The cascading file pattern is clean for teams where environment-specific non-secret values differ (API URLs, feature flags, log levels).
Large apps with many non-secret config values
Use node-config for structured config with many knobs (database pool sizes, timeouts, feature flags, pagination limits, retry counts). The nested JSON structure is easier to navigate than a flat list of env vars. Still use env vars for secrets via custom-environment-variables.json.
Enterprise apps requiring documented, validated config
Use convict when you need full documentation per config key, strict type validation at startup, automatic log redaction for sensitive values, and support for CLI arguments alongside env vars. Also consider Zod + dotenv as a more TypeScript-native alternative.
How To: The Same Config in All 5 Approaches
Compare how each approach handles the same config: a PORT, DATABASE_URL, and a DEBUG boolean.
1. Raw process.env
// No setup. Set in shell: PORT=3000 DATABASE_URL=... DEBUG=true node app.js
const PORT = parseInt(process.env.PORT || '3000', 10);
const DATABASE_URL = process.env.DATABASE_URL;
const DEBUG = process.env.DEBUG === 'true';
if (!DATABASE_URL) throw new Error('DATABASE_URL is required');2. dotenv
// .env
PORT=3000
DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
DEBUG=true
// app.js
require('dotenv').config();
const PORT = parseInt(process.env.PORT || '3000', 10);
const DATABASE_URL = process.env.DATABASE_URL;
const DEBUG = process.env.DEBUG === 'true';
if (!DATABASE_URL) throw new Error('DATABASE_URL is required');3. dotenv-flow
# .env (committed)
PORT=3000
DEBUG=false
# .env.development (committed)
DEBUG=true
# .env.development.local (not committed)
DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp_dev
// app.js
require('dotenv-flow').config();
const PORT = parseInt(process.env.PORT || '3000', 10);
const DATABASE_URL = process.env.DATABASE_URL;
const DEBUG = process.env.DEBUG === 'true';4. node-config
// config/default.json
{ "server": { "port": 3000 }, "debug": false }
// config/development.json
{ "debug": true }
// config/custom-environment-variables.json
{ "database": { "url": "DATABASE_URL" } }
// app.js
const config = require('config');
const PORT = config.get('server.port'); // Already a number!
const DATABASE_URL = config.get('database.url');
const DEBUG = config.get('debug'); // Already a boolean!5. convict
// config.js
const convict = require('convict');
const config = convict({
port: { format: 'port', default: 3000, env: 'PORT' },
databaseUrl: { format: String, default: null, env: 'DATABASE_URL', sensitive: true },
debug: { format: Boolean, default: false, env: 'DEBUG' },
});
config.validate({ allowed: 'strict' });
module.exports = config;
// app.js
const config = require('./config');
const PORT = config.get('port'); // number
const DATABASE_URL = config.get('databaseUrl'); // string
const DEBUG = config.get('debug'); // booleanBonus: Zod + dotenv (Best for TypeScript in 2026)
Combining dotenv with Zod validation gives you the simplicity of dotenv with full type safety, runtime validation, and TypeScript inference — no separate config schema library needed.
// npm install dotenv zod
// lib/env.ts
import { z } from 'zod';
import 'dotenv/config';
const envSchema = z.object({
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
DEBUG: z.coerce.boolean().default(false),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
STRIPE_SECRET_KEY: z.string().startsWith('sk_').optional(),
});
const result = envSchema.safeParse(process.env);
if (!result.success) {
const issues = result.error.flatten().fieldErrors;
console.error('Invalid environment variables:', JSON.stringify(issues, null, 2));
process.exit(1);
}
export const env = result.data;
// env.PORT is typed as number
// env.DATABASE_URL is typed as string
// env.DEBUG is typed as boolean
// Usage anywhere in your app:
// import { env } from '@/lib/env';
// app.listen(env.PORT);Comparison Table
| Approach | Env support | Validation | Type coercion | File format | Complexity |
|---|---|---|---|---|---|
| raw process.env | Shell / CI only | Manual | Manual | None | Minimal |
| dotenv | .env file + shell | Manual | Manual | .env | Low |
| dotenv + Zod | .env file + shell | Schema | z.coerce.* | .env | Low |
| dotenv-flow | Cascading .env files | Manual | Manual | .env.* | Medium |
| node-config | Files + env vars | Partial | From JSON | JSON/YAML/JS | Medium |
| convict | Env vars + files + CLI | Full schema | Full | JSON + schema | High |
Why "Just Use dotenv" Works for 90% of Projects
The Node.js ecosystem has dozens of configuration libraries, but the vast majority of production applications use dotenv (or dotenv built into their framework) for a simple reason: it solves the actual problem — getting local secrets out of source code — with zero cognitive overhead.
When dotenv is enough
- Your app has under 20 config values
- You deploy to Vercel, Railway, or Heroku (platform manages env vars)
- You add Zod validation for type safety
- You are building a Next.js or Express app
When to reach for more
- 50+ config values that differ by environment
- You need non-secret config values committed to git and synced across team
- You need audit logs for every config access
- You need CLI argument overrides for config values
Recommendation for 2026: Start with dotenv + Zod validation. It gives you type safety, runtime validation, and full TypeScript inference — covering everything most projects need. Only migrate to node-config or convict when you hit a real limitation with this approach.
Frequently Asked Questions
What is the difference between process.env and dotenv?
process.env is a built-in Node.js object holding all environment variables set by the shell or operating system before the process started. dotenv is an npm package that reads a .env file and merges its values into process.env. After calling require('dotenv').config(), both sources are merged. dotenv is the loader — process.env is where everything ends up.
When should I use node-config instead of dotenv?
Use node-config when you have many configuration values that differ by environment AND those values are not secrets. node-config is excellent for feature flags, timeouts, pagination limits, API base URLs, and connection pool sizes — values you want to commit to git and keep in sync across your team. For secrets (API keys, database passwords), always use environment variables regardless of which config library you use.
What is convict npm package?
convict is a configuration management library by Mozilla that wraps your configuration in a schema. You define each config key with its type, default value, environment variable name, documentation string, and allowed values. convict validates the entire config at startup and provides descriptive error messages. It also auto-redacts sensitive values in log output. In 2026, many TypeScript projects use Zod + dotenv instead of convict for a more type-native experience.
How do I validate process.env in Node.js?
Three approaches: (1) Manual — write a function that checks each key and throws if missing. (2) Zod — use z.object({}).parse(process.env) with z.coerce.number() and z.coerce.boolean() for type coercion. This is the recommended approach for TypeScript projects in 2026. (3) convict — define a full schema with types and defaults. Zod is the most popular choice because it handles both validation and TypeScript type inference in one step.
Should I use dotenv in production?
No — do not deploy .env files to production. Set environment variables through your hosting platform: Vercel Environment Variables, Railway Variables, Heroku Config Vars, or AWS Secrets Manager. However, leaving require('dotenv').config() in your production code is harmless — it silently does nothing when no .env file exists. Never use dotenv as a substitute for proper production secret management.
Related Guides & Tools
Continue learning about Node.js configuration and secrets:
Share this article with Your Friends, Collegue and Team mates
Stay Updated
Get the latest tool updates, new features, and developer tips delivered to your inbox.
Occasional useful updates only. Unsubscribe in one click — we never sell your email.
Feedback for process.env vs dotenv vs config files Guide
Tell us what's working, what's broken, or what you wish we built next — it directly shapes our roadmap.
Good feedback is gold — a rough edge you hit today could be smoother for everyone tomorrow.
- Feature ideas often jump the queue when lots of you ask.
- Bug reports with steps get fixed faster — paste URLs or examples if you can.
- Name and email are optional; we won't use them for anything except replying if needed.