DEV Community

Alex Chen
Alex Chen

Posted on

Environment Variables in Node.js: The Complete Guide (2026)

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)
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode
// .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),
});
Enter fullscreen mode Exit fullscreen mode

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'));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 })"
Enter fullscreen mode Exit fullscreen mode

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)