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
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
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
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
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
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
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;
}
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"]
# 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
# 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
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_*
How do you manage environment variables in your projects? Dotenv or something else?
Follow @armorbreak for more practical developer guides.
Top comments (1)
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...