Back to Developer's Study Materials

process.env vs dotenv vs config files — Which Should You Use in Node.js?

A complete comparison of Node.js configuration management approaches with real code examples (2026)

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:

Simpler
More complex

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

1

Raw process.env

No packages, just system environment variables

Zero dependencies

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 string

Pros

  • 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
2

dotenv

Load .env files into process.env — the industry standard

Most popular

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
3

dotenv-flow

Cascading .env files for multiple environments

Multi-env

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
4

config npm package (node-config)

JSON/YAML config files with environment overrides

File-based

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.json

Pros

  • 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
5

convict

Schema-validated config with types, defaults, and documentation

Enterprise

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

Bonus: 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

ApproachEnv supportValidationType coercionFile formatComplexity
raw process.envShell / CI onlyManualManualNoneMinimal
dotenv.env file + shellManualManual.envLow
dotenv + Zod.env file + shellSchemaz.coerce.*.envLow
dotenv-flowCascading .env filesManualManual.env.*Medium
node-configFiles + env varsPartialFrom JSONJSON/YAML/JSMedium
convictEnv vars + files + CLIFull schemaFullJSON + schemaHigh

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.

You make the difference

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.