Node.js Environment Variables: The Right Way to Handle Config and Secrets
Environment variables are how your application knows it's in development vs production, where the database lives, and what secrets to use. Getting this wrong is a common source of bugs, security vulnerabilities, and "works on my machine" problems.
The Basics: process.env
// Access any environment variable
const port = process.env.PORT;
const nodeEnv = process.env.NODE_ENV;
const dbUrl = process.env.DATABASE_URL;
// Always provide defaults for optional vars
const port = process.env.PORT || 3000;
const logLevel = process.env.LOG_LEVEL || 'info';
Setting Up dotenv
The most common approach for local development:
npm install dotenv
# .env file (in project root)
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=my-dev-secret-key
REDIS_URL=redis://localhost:6379
STRIPE_KEY=sk_test_xxxxx
// Load at the very start of your app — before any other imports that use env vars
import 'dotenv/config'; // ESM
// OR
require('dotenv').config(); // CommonJS
// Now process.env is populated
console.log(process.env.PORT); // 3000
Critical: Add .env to .gitignore!
# .gitignore
.env
.env.local
.env.*.local
Multiple Environment Files
.env # Base defaults (commit this — no secrets!)
.env.local # Local overrides (don't commit)
.env.development # Development-specific (can commit if no secrets)
.env.production # Production (don't commit — use real secrets manager)
.env.test # Test environment
// Load environment-specific file
const envFile = `.env.${process.env.NODE_ENV || 'development'}`;
require('dotenv').config({ path: envFile });
// Or use dotenv-flow which handles this automatically
npm install dotenv-flow
require('dotenv-flow').config();
Validating Environment Variables
Never let your app start with missing critical config!
// src/config/env.ts — validate at startup
import { z } from 'zod';
const envSchema = z.object({
// Required
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
// Required with specific values
NODE_ENV: z.enum(['development', 'production', 'test']),
// Optional with defaults
PORT: z.string().transform(Number).default('3000'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
RATE_LIMIT_MAX: z.string().transform(Number).default('100'),
// Optional
REDIS_URL: z.string().url().optional(),
SENTRY_DSN: z.string().url().optional(),
});
// Validate — throws if anything is wrong
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Invalid environment variables:');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1); // Stop the app immediately
}
export const env = parsed.data;
// env.PORT is now typed as number, not string
// Use throughout your app
import { env } from './config/env';
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT} in ${env.NODE_ENV} mode`);
});
Provide a .env.example File
Always commit a template showing what variables are needed:
# .env.example — commit this to version control
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=change-this-to-a-random-32-char-string
REDIS_URL=redis://localhost:6379
# Optional
SENTRY_DSN=
STRIPE_KEY=
New developers run cp .env.example .env and fill in values.
Security Best Practices
Never Log Secrets
// ❌ Never do this
console.log('Starting with config:', process.env);
console.log('DB URL:', process.env.DATABASE_URL); // Leaks credentials
// ✅ Log only safe values
console.log('Starting in', process.env.NODE_ENV, 'mode');
console.log('Server port:', process.env.PORT);
console.log('DB connected:', !!process.env.DATABASE_URL); // Just confirm it exists
Use Strong Secrets
# Generate a cryptographically random secret
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Output: a1b2c3d4e5f6... (64 char hex string)
# Or use your token generator tool
Principle of Least Privilege
# Create separate DB users for different environments
# Development: read + write
# Production: only the permissions your app needs
# CI/CD: potentially read-only for test runs
Docker and Container Environments
# Dockerfile — NEVER hardcode secrets
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Don't use ENV for secrets in Dockerfile!
# These get baked into the image layer
# ENV JWT_SECRET=mysecret ❌
EXPOSE 3000
CMD ["node", "dist/server.js"]
# docker-compose.yml — use env_file or environment
services:
api:
build: .
env_file:
- .env # Load from file
environment:
- NODE_ENV=production # Override specific values
ports:
- "3000:3000"
Production Secrets Management
For production, avoid .env files on servers. Use proper secrets managers:
| Platform | Tool |
|---|---|
| AWS | Secrets Manager / Parameter Store |
| Google Cloud | Secret Manager |
| Azure | Key Vault |
| Kubernetes | K8s Secrets + Sealed Secrets |
| Heroku/Railway | Platform dashboard env vars |
| Doppler | Sync secrets to any platform |
// Example: AWS Secrets Manager
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({ region: 'us-east-1' });
async function getSecret(secretName) {
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretName })
);
return JSON.parse(response.SecretString);
}
// At startup
const secrets = await getSecret('myapp/production/database');
process.env.DATABASE_URL = secrets.url;
Common Patterns
Type-Safe Config Object
// Central config — single source of truth
export const config = {
server: {
port: Number(process.env.PORT) || 3000,
env: process.env.NODE_ENV || 'development',
isProduction: process.env.NODE_ENV === 'production',
},
database: {
url: process.env.DATABASE_URL!,
poolSize: Number(process.env.DB_POOL_SIZE) || 10,
},
auth: {
jwtSecret: process.env.JWT_SECRET!,
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
},
redis: {
url: process.env.REDIS_URL,
enabled: !!process.env.REDIS_URL,
},
} as const;
Feature Flags via Env Vars
const features = {
enableBetaUI: process.env.ENABLE_BETA_UI === 'true',
maxUploadSizeMB: Number(process.env.MAX_UPLOAD_SIZE_MB) || 10,
maintenanceMode: process.env.MAINTENANCE_MODE === 'true',
};
if (features.maintenanceMode) {
app.use((req, res) => {
res.status(503).json({ message: 'Service temporarily unavailable' });
});
}
Summary
| Rule | Why |
|---|---|
Never commit .env |
Contains real secrets |
Always commit .env.example |
Documents required config |
| Validate at startup | Fail fast with clear errors |
| Use a secrets manager in production | Secure, auditable, rotatable |
| Never log sensitive values | Logs end up in many places |
| Use a typed config object | Catch typos and type errors |
→ Generate secure random secrets with the Token Generator tool.