Environment Variables in Node.js: The Complete Guide (2026)
Environment variables are the backbone of configuration management. Here's everything you need to use them correctly.
What Are Environment Variables & Why They Matter
Environment variables (env vars) are key-value pairs
that live OUTSIDE your code but INSIDE the runtime environment.
Why they matter:
→ Separate config from code (12-Factor App methodology)
→ Different settings per environment (dev/staging/prod)
→ Security: secrets never in source code or git
→ Easy deployment: same code, different config
→ Container/Docker friendly (passed at runtime)
What goes in env vars:
✅ API keys and secrets
✅ Database URLs and credentials
✅ Feature flags
✅ Service endpoints
✅ Port numbers
✅ Log levels
✅ Third-party service config
What should NOT go in env vars:
❌ Large JSON configs (use config files)
❌ Multi-line content (use files or secrets managers)
❌ Values that change frequently during runtime (use DB/cache)
Reading Env Vars in Node.js
// Method 1: process.env (built-in, always available)
const dbHost = process.env.DB_HOST;
const dbPort = process.env.DB_PORT || 5432; // Default value
const nodeEnv = process.env.NODE_ENV || 'development';
// ⚠️ CRITICAL: process.env values are ALWAYS strings!
// Even if you set PORT=3000, it's the string "3000", not number 3000
// Convert types properly:
const port = parseInt(process.env.PORT || '3000', 10);
const debug = process.env.DEBUG === 'true'; // String → boolean
const timeout = parseFloat(process.env.TIMEOUT || '30');
const retries = Number(process.env.RETRIES ?? '3');
// Method 2: dotenv (most popular — loads .env file into process.env)
// Install: npm install dotenv
require('dotenv').config({ path: '.env.local' }); // Loads .env.local into process.env
// Now available via process.env:
console.log(process.env.API_KEY); // Works!
// Method 3: dotenv-safe (for TypeScript/projects with required vars)
// Ensures all required variables exist at startup!
// .env.example lists ALL required variables
// If any missing → crashes immediately with clear error message
require('dotenv-safe').config({
example: '.env.example',
allowEmptyValues: false,
});
// Method 4: envalid (type-checked, validated env vars)
const { cleanEnv, str, num, bool, json } = require('envalid');
const env = cleanEnv(process.env, {
NODE_ENV: str({ choices: ['development', 'test', 'staging', 'production'] }),
PORT: num({ default: 3000 }),
DATABASE_URL: str(),
DEBUG: bool({ default: false }),
ALLOWED_ORIGINS: json({ default: [] }),
API_TIMEOUT: num({ devDefault: 10000 }), // Different default in dev!
});
// env.PORT is now a NUMBER, not a string!
// If DATABASE_URL is missing → crash with error message
// If NODE_ENV is "banana" → crash with valid choices listed
// Method 5: tenv (TypeScript-native, zero-dependency)
import { tenv } from '@tenv/core';
export const env = tenv({
NODE_ENV: tenv.enum(['development', 'production']),
PORT: tenv.number({ default: 3000 }),
DATABASE_URL: tenv.string(),
});
The .env File Ecosystem
# === .env === (Never commit this!)
# Contains actual secret values
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
API_KEY=sk_live_abc123xyz
JWT_SECRET=my-super-secret-key-min-32-chars
REDIS_URL=redis://localhost:6379
NODE_ENV=development
PORT=3000
DEBUG=true
# === .env.example === (Commit this! Template for new team members)
# Shows what variables are needed without revealing values
DATABASE_URL=
API_KEY=
JWT_SECRET=
REDIS_URL=
NODE_ENV=development
PORT=3000
DEBUG=
# === .env.local === (Your personal overrides, gitignored)
# For local development only
DEBUG=true
PORT=3001
# === .env.production === (Production values, stored securely)
NODE_ENV=production
PORT=3000
DEBUG=false
DATABASE_URL=${PROD_DB_URL} # Can reference other vars!
// .gitignore MUST include:
.env
.env.local
.env.*.local
!.env.example # Commit the example!
// Load order (later files override earlier):
// 1. System environment variables (OS-level)
// 2. .env (generic defaults)
// 3. .env.{NODE_ENV} (.env.development, .env.production)
// 4. .env.local (local overrides, gitignored)
// With dotenv:
require('dotenv').config({
path: [
'.env',
`.env.${process.env.NODE_ENV}`,
'.env.local',
].filter(fs.existsSync),
});
Security Best Practices
// ❌ DANGEROUS: Secrets in code
const apiKey = 'sk_live_abc123'; // Visible in git history forever!
// ❌ DANGEROUS: Logging env vars
console.log(process.env); // Leaks ALL variables including secrets!
logger.info(`Starting with DB: ${process.env.DB_URL}`); // Logs credentials!
// ✅ SAFE: Use dotenv for loading
require('dotenv').config();
// .env is in .gitignore, never committed
// ✅ SAFE: Validate required vars at startup
function requireEnv(name) {
const value = process.env[name];
if (!value) {
console.error(`FATAL: ${name} environment variable is required`);
process.exit(1); // Fail fast, don't run with missing config
}
return value;
}
const dbUrl = requireEnv('DATABASE_URL');
// ✅ SAFE: Mask sensitive values in logs
function maskSecret(value) {
if (!value || value.length < 8) return '***';
return value.slice(0, 4) + '***' + value.slice(-2);
}
logger.info(`Using API key: ${maskSecret(process.env.API_KEY)}`);
// Output: Using API key: sk_l***yz
// ✅ SAFE: Use secrets manager in production
// Options:
// - AWS Parameter Store / Secrets Manager
// - HashiCorp Vault
// - Docker secrets (/run/secrets/)
// - Kubernetes secrets
// - Cloudflare Workers secrets (environment variables encrypted at rest)
// Docker secrets example:
const dbPassword = fs.readFileSync('/run/secrets/db_password', 'utf8').trim();
// Kubernetes ConfigMap / Secret mounted as file:
const config = JSON.parse(fs.readFileSync('/etc/app/config.json', 'utf8'));
Configuration Patterns for Real Apps
// config/index.js — Centralized, validated, typed config
class Config {
constructor() {
this.isDev = ['development', 'test'].includes(this.nodeEnv);
this.isProd = this.nodeEnv === 'production';
this.isTest = this.nodeEnv === 'test';
Object.freeze(this); // Prevent accidental mutation
}
get nodeEnv() { return process.env.NODE_ENV || 'development'; }
get port() { return parseInt(process.env.PORT || '3000', 10); }
get databaseUrl() { return this._required('DATABASE_URL'); }
get jwtSecret() {
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters');
}
return secret;
}
get corsOrigins() {
const origins = process.env.CORS_ORIGINS;
if (!origins) return ['http://localhost:3000'];
return origins.split(',').map(o => o.trim());
}
get rateLimitWindow() { return parseInt(process.env.RATE_WINDOW || '900000', 10); }
get rateLimitMax() { return parseInt(process.env.RATE_LIMIT_MAX || '100', 10); }
_required(name) {
const value = process.env[name];
if (!value) throw new Error(`Missing required env var: ${name}`);
return value;
}
}
// Singleton: one config instance for entire app
module.exports = new Config();
// Usage anywhere:
const config = require('./config');
if (config.isProd && config.debug) {
logger.warn('Debug mode should not be enabled in production!');
}
app.listen(config.port);
Debugging Env Var Issues
# Problem: Variable not showing up
echo $MY_VAR # Check if it's set in shell
printenv MY_VAR # Alternative check
node -e "console.log(process.env.MY_VAR)" # Check if Node can see it
# Problem: .env file not loading
# Check: Is .gitignore blocking it? (Shouldn't matter for loading)
# Check: Is dotenv.config() called BEFORE other imports?
# Check: Is the path correct? (resolve from cwd)
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
# Problem: Value has trailing whitespace/newline
DB_HOST=localhost
# ^^^ Has invisible newline! Trim it:
const host = (process.env.DB_HOST || '').trim();
# Problem: Special characters breaking things
PASSWORD="p@ss$w0rd!" # $ triggers shell expansion!
# Solution: Use single quotes: PASSWORD='p@ss$w0rd!'
# Or escape: PASSWORD="p@ss\$w0rd\!"
# Problem: Spaces around equals sign
PORT = 3000 # ❌ Creates var "PORT " with value " 3000"
PORT=3000 # ✅ No spaces
# Problem: Quotes included in value
NAME="Alice" # process.env.NAME = "Alice" (with quotes!)
# Some tools strip quotes, some don't. Be consistent.
# Quick debug: Print all env vars (BE CAREFUL with secrets!)
node -e "console.log(JSON.stringify(process.env, null, 2))"
# Or just check specific ones:
node -e "console.log({ NODE_ENV: process.env.NODE_ENV, PORT: process.env.PORT })"
What's your biggest frustration with environment variables? How do you manage secrets across environments?
Follow @armorbreak for more practical developer guides.
Top comments (0)