Environment Variables
Keep secrets secret and configuration flexible with environment variables.
The Configuration Problem#
Your app needs configuration: database URLs, API keys, port numbers. But different environments need different values:
- Development: Local database, debug logging
- Staging: Test database, normal logging
- Production: Production database, minimal logging
Hardcoding these values means changing code for each environment. That's error-prone and exposes secrets in your repository.
Environment variables solve this.
What Are Environment Variables?#
Environment variables are key-value pairs that exist outside your code. Your operating system provides them, and your app reads them at runtime.
# Set an environment variable (terminal)
export PORT=3000
# Read it in Node.js
console.log(process.env.PORT); // "3000"
Every value is a string. You'll need to parse numbers and booleans yourself.
The .env File#
Instead of setting variables manually, use a .env file:
# .env
PORT=3000
NODE_ENV=development
# Database
MONGODB_URI=mongodb://localhost:27017/myapp
REDIS_URL=redis://localhost:6379
# Auth
JWT_SECRET=my-super-secret-key-change-in-production
JWT_EXPIRES_IN=7d
# External Services
STRIPE_API_KEY=sk_test_...
SENDGRID_API_KEY=SG...
Use the dotenv package to load this file:
// Load at the very start of your app
import 'dotenv/config';
// Now process.env has all your variables
console.log(process.env.PORT); // "3000"
Never Commit .env
Add .env to .gitignore immediately. It contains secrets that shouldn't be in your repository.
The .env.example File#
Since .env isn't committed, teammates won't know what variables they need. Create .env.example:
# .env.example - commit this file
# Copy to .env and fill in real values
PORT=3000
NODE_ENV=development
# Database
MONGODB_URI=mongodb://localhost:27017/myapp
REDIS_URL=redis://localhost:6379
# Auth (generate a secure secret for production)
JWT_SECRET=your-secret-here
JWT_EXPIRES_IN=7d
# External Services
STRIPE_API_KEY=
SENDGRID_API_KEY=
New developers copy this file and fill in their values:
cp .env.example .env
# Edit .env with real values
Configuration Module#
Don't scatter process.env calls throughout your code. Centralize configuration:
// src/config/index.js
export const config = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT, 10) || 3000,
// Database
mongoUri: process.env.MONGODB_URI,
redisUrl: process.env.REDIS_URL,
// Auth
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
},
// External
stripe: {
apiKey: process.env.STRIPE_API_KEY,
},
// Feature flags
features: {
enableMetrics: process.env.ENABLE_METRICS === 'true',
},
};
// Validate required config
const required = ['mongoUri', 'jwt.secret'];
for (const key of required) {
const value = key.split('.').reduce((obj, k) => obj?.[k], config);
if (!value) {
throw new Error(`Missing required config: ${key}`);
}
}
Why Centralize?#
- Type conversion - Parse strings to numbers/booleans once
- Defaults - Provide sensible fallbacks
- Validation - Fail fast if config is missing
- Autocomplete - IDEs can suggest
config.portbut notprocess.env.PORT
Now use config everywhere:
// Instead of this:
app.listen(parseInt(process.env.PORT, 10) || 3000);
// Do this:
import { config } from './config/index.js';
app.listen(config.port);
Type Conversion#
Environment variables are always strings. Convert them:
// Numbers
const port = parseInt(process.env.PORT, 10);
const timeout = parseFloat(process.env.TIMEOUT);
// Booleans
const debug = process.env.DEBUG === 'true';
const enabled = process.env.FEATURE_X !== 'false';
// Arrays (comma-separated)
const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || ['*'];
// CORS_ORIGINS=http://localhost:3000,https://myapp.com
// Result: ['http://localhost:3000', 'https://myapp.com']
// JSON (for complex config)
const settings = JSON.parse(process.env.SETTINGS || '{}');
Environment-Specific Behavior#
Use NODE_ENV to change behavior:
// src/config/index.js
export const config = {
env: process.env.NODE_ENV || 'development',
isDev: process.env.NODE_ENV === 'development',
isProd: process.env.NODE_ENV === 'production',
isTest: process.env.NODE_ENV === 'test',
};
// Usage
if (config.isDev) {
app.use(morgan('dev')); // Detailed logging in dev
}
if (config.isProd) {
app.set('trust proxy', 1); // Trust load balancer
}
Common NODE_ENV values:
development- Local machinetest- Running testsstaging- Pre-production environmentproduction- Live environment
Multiple .env Files#
For complex setups, use multiple files:
.env # Loaded always
.env.local # Local overrides (gitignored)
.env.development # Development defaults
.env.production # Production defaults
.env.test # Test defaults
Load them conditionally:
import dotenv from 'dotenv';
// Load base .env
dotenv.config();
// Load environment-specific
dotenv.config({ path: `.env.${process.env.NODE_ENV}` });
// Load local overrides (if exists)
dotenv.config({ path: '.env.local' });
Load Order
Later files override earlier ones. So .env.local can override .env.development which overrides .env.
Node.js Built-in --env-file#
Node.js 20+ has built-in support for .env files:
# No dotenv package needed!
node --env-file=.env src/index.js
# Multiple files
node --env-file=.env --env-file=.env.local src/index.js
Update your package.json scripts:
{
"scripts": {
"dev": "node --watch --env-file=.env src/index.js",
"start": "node --env-file=.env src/index.js"
}
}
Secrets in Production#
Don't put production secrets in .env files on servers. Use proper secret management:
Platform Secrets#
Most platforms have built-in secret storage:
- Heroku: Config vars in dashboard
- Vercel: Environment variables in project settings
- AWS: Parameter Store, Secrets Manager
- Docker: Docker secrets, environment in compose
# docker-compose.yml
services:
api:
environment:
- NODE_ENV=production
- PORT=3000
- MONGODB_URI=${MONGODB_URI} # From host environment
env_file:
- .env.production
Best Practices for Secrets#
- Never commit secrets - Use
.gitignore - Rotate secrets regularly - Especially if exposed
- Use different secrets per environment - Dev JWT secret ≠ Prod JWT secret
- Minimize access - Only give secrets to services that need them
Validation with Zod#
For robust validation, use Zod:
// src/config/index.js
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().transform(Number).default('3000'),
MONGODB_URI: z.string().url(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('7d'),
});
// This throws if validation fails
const env = envSchema.parse(process.env);
export const config = {
env: env.NODE_ENV,
port: env.PORT,
mongoUri: env.MONGODB_URI,
jwt: {
secret: env.JWT_SECRET,
expiresIn: env.JWT_EXPIRES_IN,
},
};
Now you get:
- Type safety
- Automatic parsing
- Clear error messages
- Default values
Complete Example#
# .env.example
NODE_ENV=development
PORT=3000
# Database
MONGODB_URI=mongodb://localhost:27017/myapp
REDIS_URL=redis://localhost:6379
# Auth
JWT_SECRET=change-this-to-a-long-random-string-at-least-32-chars
JWT_EXPIRES_IN=7d
# Logging
LOG_LEVEL=debug
# External APIs
STRIPE_API_KEY=
SENDGRID_API_KEY=
# Feature Flags
ENABLE_METRICS=false
// src/config/index.js
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().transform(Number).default('3000'),
MONGODB_URI: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('7d'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
STRIPE_API_KEY: z.string().optional(),
SENDGRID_API_KEY: z.string().optional(),
ENABLE_METRICS: z.string().transform(v => v === 'true').default('false'),
});
const env = envSchema.parse(process.env);
export const config = {
env: env.NODE_ENV,
isDev: env.NODE_ENV === 'development',
isProd: env.NODE_ENV === 'production',
port: env.PORT,
mongo: { uri: env.MONGODB_URI },
redis: { url: env.REDIS_URL },
jwt: {
secret: env.JWT_SECRET,
expiresIn: env.JWT_EXPIRES_IN,
},
log: { level: env.LOG_LEVEL },
stripe: { apiKey: env.STRIPE_API_KEY },
sendgrid: { apiKey: env.SENDGRID_API_KEY },
features: { metrics: env.ENABLE_METRICS },
};
Key Takeaways#
- Never hardcode configuration - Use environment variables
- Never commit .env - Add to
.gitignore, provide.env.example - Centralize config - One module, one source of truth
- Validate and convert - Env vars are strings, parse them
- Fail fast - Missing required config should crash at startup, not runtime
The Pattern
.env for local dev, platform secrets for production, centralized config module for your code. This pattern scales from hobby projects to enterprise systems.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.