Environment Variables Done Right: The .env Guide Every Developer Needs (2026)
Stop hardcoding secrets. Stop losing config between environments. Here's how to manage env vars like a pro.
Why Environment Variables Matter
// ❌ NEVER do this
const DB_PASSWORD = 'SuperSecret123!';
const API_KEY = 'sk-live-abc123456789';
const STRIPE_SECRET = 'sk_live_...';
// These end up in git, in logs, in your brain (worst of all)
// ✅ ALWAYS do this
const DB_PASSWORD = process.env.DB_PASSWORD;
const API_KEY = process.env.API_KEY;
The Basics
What Are Environment Variables?
Key-value pairs available to your process:
# Set in shell
export NODE_ENV=production
export PORT=3000
# Pass when running command
PORT=8080 node server.js
# From a file (.env)
DB_HOST=localhost DB_PORT=5432 node server.js
In Node.js
// All env vars live on process.env
console.log(process.env.HOME); // /root
console.log(process.env.PATH); // /usr/bin:...
console.log(process.env.NODE_ENV); // undefined (unless set)
console.log(process.env.MY_VAR); // whatever you set it to
The Standard: dotenv
npm install dotenv
// src/config/index.js — Load as early as possible
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Load .env from project root (goes up from src/)
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
// Now all vars are on process.env!
console.log(process.env.DATABASE_URL);
.env File Format
# .env — NEVER commit this file!
# Server
NODE_ENV=development
PORT=3000
HOST=0.0.0.0
# Database
DATABASE_URL=postgres://user:pass@localhost:5432/myapp
DATABASE_POOL_SIZE=10
# Redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# JWT
JWT_SECRET=your-super-secret-key-at-least-32-chars
JWT_EXPIRES_IN=7d
# External APIs
OPENAI_API_KEY=sk-...
STRIPE_SECRET_KEY=sk_live_...
# Feature flags
ENABLE_CACHE=true
RATE_LIMIT=100
.env.example — For Your Team
# .env.example — COMMIT THIS FILE!
# Copy this to .env and fill in your values:
cp .env.example .env
# Server
NODE_ENV=development
PORT=3000
# Database
DATABASE_URL=postgres://user:pass@localhost:5432/myapp
DATABASE_POOL_SIZE=10
# Redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# JWT — Generate with: openssl rand -base64 32
JWT_SECRET=change-me-in-production
# External APIs
OPENAI_API_KEY=
STRIPE_SECRET_KEY=
# Feature flags
ENABLE_CACHE=true
RATE_LIMIT=100
Validation (Don't Skip This!)
// src/config/index.js with validation
import dotenv from 'dotenv';
import path from 'path';
dotenv.config();
const requiredVars = [
'NODE_ENV',
'PORT',
'DATABASE_URL',
'JWT_SECRET',
];
const missingVars = requiredVars.filter(key => !process.env[key]);
if (missingVars.length > 0) {
console.error('❌ Missing environment variables:');
missingVars.forEach(v => console.error(` - ${v}`));
console.error('\nCopy .env.example to .env and fill in the values.');
process.exit(1);
}
// Type-safe config with defaults
const config = {
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT) || 3000,
database: {
url: process.env.DATABASE_URL,
poolSize: parseInt(process.env.DATABASE_POOL_SIZE) || 10,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
},
isDev: () => (process.env.NODE_ENV || 'development') === 'development',
isProd: () => process.env.NODE_ENV === 'production',
isTest: () => process.env.NODE_ENV === 'test',
};
// Freeze in production to prevent accidental mutation
if (config.isProd()) {
Object.freeze(config);
}
export default config;
Multi-Environment Setup
project/
├── .env # Local dev (gitignored)
├── .env.development # Dev overrides (optional)
├── .env.production # Production template (committed)
├── .env.test # Test values (committed)
├── .env.example # Template for new devs (committed)
└── .gitignore # Includes .env, .env.local
// Choose which .env file based on NODE_ENV
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const envFile = `.env.${process.env.NODE_ENV || 'development'}`;
dotenv.config({
path: path.resolve(__dirname, `../../${envFile}`),
override: true
});
// Fallback to default .env
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
Docker + Environment Variables
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://app:secret@db:5432/appdb
- REDIS_URL=redis://redis:6379
env_file:
- .env.production # Load from file
depends_on:
- db
- redis
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
redis:
image: redis:7-alpine
Best practice: Use environment for shared/secrets and env_file for non-sensitive defaults.
Security Rules
1. Never Commit .env
# .gitignore (add these lines)
.env
.env.local
.env.*.local
*.env
!.env.example
!.env.production.template
!.env.test
2. Use Different Keys Per Environment
// Development can use weaker keys
// Production MUST use strong keys
if (config.isProd() && config.jwt.secret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters in production');
}
3. Rotate Secrets Regularly
# Generate new secrets
openssl rand -base64 32 # JWT secret
openssl rand -hex 32 # API key format
# Update .env and restart:
# 1. Change value in .env
# 2. Restart application
# 3. Update any external services that used old key
4. Don't Log Env Vars
// ❌ BAD
app.use((req, res, next) => {
console.log('Config:', process.env); // Leaks ALL secrets!
next();
});
// ✅ GOOD
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
});
Debugging Common Issues
| Problem | Cause | Fix |
|---|---|---|
undefined when reading var |
.env not loaded | Check dotenv.config() runs before imports |
| Var loads in dev but not prod | .env not deployed | Add to docker-compose or CI secrets |
| Special characters break | Quotes missing | Wrap values in quotes: MSG="Hello World"
|
| Multiline value fails | No \n support |
Use JSON or base64 for complex values |
| Test uses production values | Wrong .env file loaded | Explicitly set NODE_ENV=test
|
Advanced: Vault Integration (When You Scale)
For teams, use a secret manager instead of .env files:
// Using HashiCorp Vault (simplified)
async function loadFromVault() {
if (process.env.VAULT_ADDR) {
const response = await fetch(`${process.env.VAULT_ADDR}/v1/secret/data/app`);
const { data } = await response.json();
Object.entries(data.data).forEach(([key, val]) => {
if (!process.env[key]) {
process.env[key] = val;
}
});
}
}
// Falls back to .env if no vault configured
await loadFromVault();
require('dotenv').config();
Quick Reference
# CLI operations
export MY_VAR="hello" # Set for current session
unset MY_VAR # Remove
env # Show all vars
printenv | grep NODE # Filter vars
source .env # Load into shell (not recommended)
# Node.js
process.env.MY_VAR # Read
process.env.MY_VAR = 'value' # Write (affects current process only)
delete process.env.MY_VAR # Remove
How do you manage environment variables? Still using .env everywhere or moved to something else?
Follow @armorbreak for more practical Node.js guides.
Top comments (1)
Use varlock.dev - free and open source. It will make your life much easier and your secrets much more secure.