Every Node.js project starts the same way: a .env file appears in the repo root, process.env reads spread across the codebase, and nobody can remember which variables are required. Then a new developer joins, runs the app, and gets a cryptic undefined where a database URL should be — at 2 a.m., in production. Configuration management is the least glamorous and most expensive part of shipping software, and the library you choose determines how much of that pain you feel.

This guide compares the three config libraries that dominate the Node.js ecosystem: dotenv (20,521 stars), envalid (1,586 stars), and node-config (6,428 stars). All three are actively maintained as of August 2026, and each solves a different layer of the problem.

TL;DR — Quick Verdict

If you just need .env files loaded into process.env, use dotenv — it is the industry default, and 12-factor apps built on it are portable by definition. If you want validated, typed environment variables that fail fast on missing values, use envalid — it is the smallest library that catches configuration errors before they become runtime errors. If you need hierarchical, environment-aware config files (defaults + production overrides + local overrides), use node-config — it is the only one of the three designed for multi-environment deployment scenarios like Docker and Kubernetes. These are complementary, not competitors: envalid and node-config both load .env files via dotenv under the hood.

Quick Comparison Table

Dimensiondotenvenvalidnode-config
GitHub Stars20,5211,5866,428
Last Commit (Aug 2026)2026-08-042026-08-212026-08-18
LicenseBSD-2-ClauseMITMIT
Core jobLoad .env into process.envValidate & type env varsHierarchical config files
Config sources.env fileprocess.envJSON/JS/YAML files + env overrides
Type checkingNoneYes (str, num, bool, email, url, json)Light (via js-yaml / no runtime types)
Validation / fail-fastNoYes — crashes on missing/invalidNo built-in schema validation
Defaults supportNoYes (per-var default)Yes (default.json + env-specific files)
Environment switchingManualVia NODE_ENVAutomatic (NODE_ENV or NODE_CONFIG_ENV)
Secrets supportYes (plain env vars)Yes (env vars)Yes (env overrides, config.get)
Bundle size~3 kB~5 kB~30 kB (with js-yaml)
Best suited forAny Node app needing env filesServices that must fail fastMulti-environment deployments

Decision Matrix — Pick in 10 Seconds

Use CaseRecommended LibraryWhy
Load a .env file into a script or appdotenvOne line, zero config, industry standard
Catch missing config at startup, not at runtimeenvalidTyped schema + hard crash on invalid env
Microservices with per-environment config filesnode-configdefault.json + production.json + local.json layering
Docker/Kubernetes deployments with env injectionenvalid or node-configEnv-based overrides work in both; validate with envalid
CLI tools and small scriptsdotenvMinimal footprint, no file hierarchy to maintain
Team of 10+ with shared config conventionsnode-configCentralized file structure, deployment docs, mature tooling

dotenv — The One-Line Standard

dotenv is the most-installed configuration package in the Node ecosystem. It reads a .env file at the project root and merges its KEY=VALUE pairs into process.env — nothing more, nothing less. Its genius is that it makes the 12-factor “store config in the environment” pattern work locally without any infrastructure.

1
2
3
4
5
# .env
DB_HOST=localhost
DB_PORT=5432
DB_USER=app
DB_PASSWORD=super-secret
1
2
3
require('dotenv').config();

console.log(process.env.DB_HOST); // 'localhost'

The entire API is that .config() call (plus dotenv.parse() if you need to read a file manually). Where it shines: portability — the same .env works in CI, on a laptop, and in a container with --env-file. Where it struggles: nothing is validated or typed. A missing variable silently becomes undefined, and a typo like PORT=abc flows straight into your HTTP server as a NaN that only fails on the first connection.

envalid — Fail Fast, Fail Loud

envalid takes the environment-variable approach and adds the missing safety net: a declarative schema that validates and coerces every variable at startup, crashing the process with a helpful error message if anything is missing or malformed. It works in Node, Bun, and any compatible JS runtime, and it ships with common validators plus a custom-validator hook.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { cleanEnv, str, num, bool } from 'envalid';

const env = cleanEnv(process.env, {
  NODE_ENV: str({ choices: ['development', 'test', 'production'] }),
  PORT: num({ default: 3000 }),
  DEBUG: bool({ default: false }),
  DB_URL: str(),
  SENTRY_DSN: str({ default: '' }),
});

// env is fully typed: env.PORT is a number, env.DEBUG is a boolean
console.log(env.PORT * 2);

If DB_URL is missing, the process exits with:

1
Missing environment variable: "DB_URL"

Where it shines: type coercion — num() converts "8080" to 8080, bool() parses "true"/"1"/"yes", and invalid values produce precise errors instead of silent NaNs. Where it struggles: it only reads process.env — no config file hierarchy. For a handful of services that is a feature, not a bug; for complex deployments you layer it on top of node-config or a config file loader.

node-config — Hierarchical Config for Real Deployments

node-config organizes configuration as a file hierarchy: default.json holds baseline values, and environment-specific files (production.json, development.json, test.json) override them based on NODE_ENV. A local.json (git-ignored) layer lets individual developers override locally without touching shared files.

1
2
3
4
5
6
7
8
// config/default.json
{
  "Customer": {
    "dbHost": "localhost",
    "dbPort": 5432,
    "dbName": "customers"
  }
}
1
2
3
4
5
6
// config/production.json
{
  "Customer": {
    "dbHost": "db.internal.example.com"
  }
}
1
2
3
4
const config = require('config');

const dbHost = config.get('Customer.dbHost'); // resolves per NODE_ENV
const dbPort = config.get('Customer.dbPort');

Where it shines: deployment-aware layering, deep-merge semantics, and a mature ecosystem — it has been in production for over a decade and supports JSON, JS, and YAML config files. Environment variables can override individual keys too, which keeps containers and Kubernetes manifests happy. Where it struggles: no runtime validation — a typo in a key returns a loud ConfigError on .get() (good), but type mistakes in files are only caught when consumed. Pair it with envalid for env vars and a lightweight schema check for the rest.

Pitfalls & Migration Notes

  1. Never commit real secrets in .env. dotenv’s own docs recommend keeping .env out of git (.env.example in, .env out). git history is forever — a leaked AWS_SECRET_ACCESS_KEY requires rotation even after deletion.
  2. dotenv does not override existing environment variables by default. In CI or containers, injected vars win over the .env file. If you need the opposite, use dotenv.config({ override: true }) — but that surprises teams expecting standard behavior, so document it loudly.
  3. Type coercion is where config bugs hide. "PORT=abc" becomes NaN in dotenv and node-config, but envalid rejects it at startup. If you stay on dotenv, add a runtime schema check at the service boundary — or migrate the critical vars to envalid incrementally.
  4. node-config freezes its config object. The returned object is deep-frozen by default (config.util.toObject() to unfreeze). Mutating config at runtime — a common quick hack — throws in strict mode and silently does nothing otherwise. Set values at startup, never mid-request.
  5. NODE_ENV is not the only environment axis. node-config defaults to NODE_ENV (or NODE_CONFIG_ENV), but real deployments often need CUSTOM_NODE_ENV for staging variants. Configure it explicitly in your orchestrator or every environment loads default.json plus local.json.
  6. Loading order matters. If you call dotenv.config() after modules have already read process.env at import time, those modules see undefined. Load dotenv as the very first line of your entry point — before any other require/import — or use --env-file=.env with modern Node (18.20+/20.6+).
  7. Secrets vs config. Config files in git should contain zero secrets. Put credentials in environment variables (via Docker env_file, Kubernetes secrets, or your CI vault) and reference them with envalid or node-config’s env override — not in default.json.
  8. Bundle size for edge runtimes. If you deploy to serverless or edge runtimes, dotenv (~3 kB) and envalid (~5 kB) stay lean; node-config pulls in js-yaml and other deps. For edge, prefer envalid plus a tiny YAML/JSON loader.

FAQ

Can I use dotenv and envalid together? Yes, and it is a common pattern: dotenv.config() first, then cleanEnv(process.env, schema) — dotenv loads the file, envalid validates what landed in the environment.

Does node-config support environment variables? Yes. Any key can be overridden with NODE_CONFIG (JSON string) or individual CONFIG__key__path variables, and NODE_CONFIG_ENV picks the config file set. This is how Kubernetes and Docker inject overrides.

Which library is best for a monorepo? dotenv or envalid per package, with a shared schema package for validated variables. node-config’s file hierarchy becomes awkward across many packages because each package needs its own config/ directory.

Is envalid still maintained? Yes — the repository is actively maintained (last push 2026-08-21) and supports Node, Bun, and Deno-compatible runtimes via its small, dependency-free core.

How do I handle config in TypeScript projects? All three ship types. envalid infers a fully typed env object from the schema — the best developer experience. dotenv and node-config expose typed get() helpers but leave the heavy lifting to your own types.

Should I migrate from dotenv to envalid? If missing configuration has burned you in production, yes — it is a drop-in improvement: keep dotenv for loading, add an envalid schema for validation. The migration is incremental and safe because envalid reads the same process.env you already populate.

Why Config Discipline Pays Off

Configuration bugs are uniquely nasty because they surface late — the app boots fine on your laptop and fails only in the production environment with real traffic. The libraries above each remove one layer of that risk: dotenv makes local/cloud parity trivial, envalid converts missing config into an immediate, readable error, and node-config gives multi-environment deployments a structure humans can audit. None of them require a heavyweight framework, and all three are permissively licensed and free.

For the broader tooling picture, our development environment managers comparison covers mise vs asdf vs direnv for runtime versions, and the cross-language configuration libraries guide shows how Viper, koanf, and TypeSafe Config solve the same problem in Go and the JVM. If your config lives in environment variables in Go specifically, the Go env config libraries article is the direct counterpart.

Bottom line: load with dotenv, validate with envalid, and reach for node-config only when your deployment matrix genuinely needs file-based layering. Your future on-call self will thank you.


💰 想测试你的市场判断力?我用 Polymarket 做预测市场交易——这是全球最大的预测市场平台,从大选结果到技术监管时间线,什么都可以押注。和赌博不同,这是真正的信息市场:你懂的信息越多,胜率越高。我靠预测技术相关事件的走向已经赚了不少。用我的邀请链接注册:Polymarket.com