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 standard way to configure apps across environments. Here's how to use them correctly.

What and Why

What: Key-value pairs set outside your application code
Where: OS environment, .env files, CI/CD config, container orchestration
Why:
→ Separate config from code (12-Factor App methodology)
→ Same code runs everywhere (dev, staging, production)
→ Secrets never committed to git
→ Easy to change behavior without redeploying
Enter fullscreen mode Exit fullscreen mode

Reading Env Vars in Node.js

// Method 1: process.env (built-in, always available)
const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;
const apiKey = process.env.API_KEY;

// ⚠️ process.env values are ALWAYS strings!
const timeout = parseInt(process.env.TIMEOUT, 10) || 5000;
const debug = process.env.DEBUG === 'true';
const maxRetries = Number(process.env.MAX_RETRIES || '3');

// Method 2: dotenv (most popular approach)
// npm install dotenv
import 'dotenv/config'; // Loads .env into process.env automatically
// Now process.env has all your .env variables!

// Or explicit load:
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' }); // Custom file path

// Method 3: env-cmd (for package.json scripts)
// "dev": "env-cmd -f .env.dev node server.js"
// "prod": "env-cmd -f .env.prod node server.js"

// Method 4: tenv / enve (type-safe alternatives)
import { env } from 'tenv';
const port = env.number('PORT', 3000); // Type-safe with default
const dbUrl = env.string('DATABASE_URL'); // Required, throws if missing
const debug = env.bool('DEBUG', false); // Boolean parsing
Enter fullscreen mode Exit fullscreen mode

The .env File Ecosystem

# .env (committed to git with defaults)
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug

# .env.local (NOT committed! Gitignored)
# Contains local overrides and secrets
DATABASE_URL=postgresql://localhost/myapp_dev
API_KEY=sk_test_local_key
JWT_SECRET=local-dev-secret-not-for-production

# .env.production (NOT committed!)
# Production values loaded only on production servers
DATABASE_URL=postgresql://prod-server:5432/prod_db
API_KEY=sk_live_production_key
JWT_SECRET=super-secure-random-string-here
REDIS_URL=redis://prod-redis:6379

# .env.test (for test suite)
NODE_ENV=test
DATABASE_URL=sqlite::memory:
LOG_LEVEL=error
Enter fullscreen mode Exit fullscreen mode

Best Practices

Structure & Organization

# Directory structure for larger projects:
config/
  index.ts          # Exports validated config object
  database.ts       # DB connection settings
  auth.ts           # Auth-related config
  redis.ts          # Redis/cache config
  email.ts          # Email service config
  validation.ts     # Zod schemas for type safety

# .env.example (COMMITTED to git!)
# Template showing all required variables (no real values):
# Copy this to .env.local and fill in your values
cp .env.example .env.local

# .env.example contents:
# === Database ===
DATABASE_URL=postgresql://user:pass@localhost:5432/dbname
# === Server ===
PORT=3000
NODE_ENV=development
# === Auth ===
JWT_SECRET=generate-with:openssl rand -base64 32
JWT_EXPIRES_IN=15m
# === External APIs ===
OPENAI_API_KEY=sk-...
# === Redis (optional) ===
REDIS_URL=redis://localhost:6379
Enter fullscreen mode Exit fullscreen mode

Validation at Startup

// config/index.ts — Validate ALL env vars when app starts
import z from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'staging', 'production']).default('development'),
  PORT: z.coerce.number().default(3000),

  DATABASE_URL: z.string().url('Must be a valid DATABASE_URL'),
  REDIS_URL: z.string().optional(),

  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
  JWT_EXPIRES_IN: z.string().default('15m'),

  API_KEY: z.string().min(1).optional(),

  LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug']).default('info'),
  CORS_ORIGIN: z.string().default('http://localhost:3000'),

  RATE_LIMIT_WINDOW_MS: z.coerce.number().default(900000),
  RATE_LIMIT_MAX: z.coerce.number().default(100),
});

export type Env = z.infer<typeof envSchema>;

function validateEnv(): Env {
  const result = envSchema.safeParse(process.env);

  if (!result.error) {
    return result.data; // All good!
  }

  // Pretty error message listing missing/invalid vars
  const issues = result.error.issues.map(i => `  ${i.path.join('.')}: ${i.message}`);
  console.error('❌ Invalid environment variables:\n' + issues.join('\n'));
  console.error('\nCopy .env.example to .env.local and fill in the values.');
  process.exit(1); // Crash fast — don't run with bad config!
}

export const config = validateEnv();

// Now import config everywhere — fully typed, guaranteed valid!
import { config } from './config/index.js';
console.log(`Server starting on port ${config.PORT}`); // TypeScript knows it's a number
console.log(`DB: ${config.DATABASE_URL}`);             // Validated URL string
Enter fullscreen mode Exit fullscreen mode

Security Rules

# .gitignore MUST include:
.env
.env.local
.env.*.local
!.env.example          # Commit the template, not real values

# Never:
❌ Commit .env files with real secrets
❌ Log environment variables (especially secrets)
❌ Read .env files after build (they should be resolved at startup)
❌ Use fallback secrets like "secret" or "password"
❌ Store secrets in source code (even "just for testing")

# Always:
✅ Use different secrets per environment
✅ Generate secrets with proper entropy:
   openssl rand -base64 32    # For JWT secrets
   openssl rand -hex 32        # For API keys/tokens
✅ Rotate secrets regularly
✅ Use a secrets manager in production (AWS SM, Vault, etc.)
✅ Set file permissions: chmod 600 .env.local
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns

// Pattern 1: Feature flags via env vars
const features = {
  newDashboard: process.env.FEATURE_DASHBOARD === 'true',
  darkModeDefault: process.env.FEATURE_DARK_MODE === 'true',
  betaAPI: process.env.FEATURE_BETA_API === 'true',
};

if (features.newDashboard) {
  app.use('/dashboard', dashboardRouter);
}

// Pattern 2: Multi-database switching
const dbConfig = {
  sqlite: { client: 'sqlite3', connection: { filename: './data.db' } },
  postgresql: { client: 'pg', connection: process.env.DATABASE_URL },
};
const db = knex(dbConfig[process.env.DB_TYPE || 'sqlite']);

// Pattern 3: Graceful degradation
const optionalServices = {
  redis: process.env.REDIS_URL ? createRedisClient(process.env.REDIS_URL) : null,
  email: process.env.SENDGRID_API_KEY ? new SendGrid(process.env.SENDGRID_API_KEY) : null,
};

// Code that handles missing services:
async function cacheData(key, value) {
  if (optionalServices.redis) {
    await optionalServices.redis.setex(key, 3600, JSON.stringify(value));
  }
  // Continue without caching if Redis isn't configured
}

// Pattern 4: Environment-specific module loading
let logger;
switch (process.env.NODE_ENV) {
  case 'production':
    logger = (await import('./logger/winston.js')).default;
    break;
  case 'test':
    logger = (await import('./logger/silent.js')).default;
    break;
  default:
    logger = (await import('./logger/console.js')).default;
}
Enter fullscreen mode Exit fullscreen mode

Docker & CI/CD

# Dockerfile — pass env vars at runtime, NOT build time
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist ./dist
# DON'T: ENV DATABASE_URL=... here (secrets in image = bad)
CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode
# docker-compose.yml
services:
  app:
    build: .
    ports: ["3000:3000"]
    env_file:
      - .env.local            # Load from file
    environment:
      - NODE_ENV=production   # Or inline override
    depends_on:
      - db
      - redis

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: ${DB_USER:-app}        # From .env or default
      POSTGRES_PASSWORD: ${DB_PASSWORD:?err} # Error if not set!

  redis:
    image: redis:7-alpine
Enter fullscreen mode Exit fullscreen mode
# GitHub Actions (.github/workflows/ci.yml)
env:
  NODE_ENV: test
  DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
  CI: true

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/testdb
        run: npm test
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Common env var naming conventions:

Server:
  PORT, HOST, NODE_ENV, URL, BASE_URL, PUBLIC_URL

Database:
  DATABASE_URL, DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD
  REDIS_URL, CACHE_URL, MONGO_URI

Auth:
  JWT_SECRET, JWT_EXPIRES_IN, SESSION_SECRET
  OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET
  API_KEY, API_SECRET, ENCRYPTION_KEY

External Services:
  STRIPE_KEY, SENDGRID_API_KEY, OPENAI_API_KEY
  AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
  SENTRY_DSN, DATADOG_API_KEY

Feature Flags:
  FEATURE_* prefix for all feature toggles

App-specific:
  APP_NAME, LOG_LEVEL, DEBUG, VERBOSE
  CORS_ORIGIN, ALLOWED_ORIGINS
  RATE_LIMIT_*, TIMEOUT_*, MAX_*
Enter fullscreen mode Exit fullscreen mode

How do you manage environment variables in your projects? Dotenv or something else?

Follow @armorbreak for more practical developer guides.

Top comments (1)

Collapse
 
theoephraim profile image
Theo Ephraim

or use varlock.dev - free, open source. Built in validation, type safety, plugins to pull from 15 different backends. Local encryption with biometric unlock.

Trust me you'll never look back...