The Twelve-Factor App Principle
Store config in the environment. Code should be the same across environments - only configuration changes.
Same code, different config:
Development: Production:
DB_HOST=localhost DB_HOST=prod-db.example.com
DB_NAME=myapp_dev DB_NAME=myapp
LOG_LEVEL=debug LOG_LEVEL=info
JWT_SECRET=dev-secret JWT_SECRET=very-long-secure-key
Using dotenv
Installation
npm install dotenv
.env File (Never Commit!)
# .env
NODE_ENV=development
PORT=3000
# Database
MONGO_URI=mongodb://localhost:27017/myapp_dev
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
DB_USER=postgres
DB_PASSWORD=secret
# Auth
JWT_SECRET=my-dev-secret
JWT_EXPIRES_IN=24h
SESSION_SECRET=session-secret
# External Services
REDIS_URL=redis://localhost:6379
AWS_ACCESS_KEY=your-key
AWS_SECRET_KEY=your-secret
S3_BUCKET=myapp-dev-uploads
# API Keys
SENDGRID_API_KEY=sendgrid-key
STRIPE_SECRET_KEY=sk_test_...
# Logging
LOG_LEVEL=debug
Loading dotenv
// config/env.js - loaded first, before any other module
const path = require('path');
if (process.env.NODE_ENV !== 'production') {
require('dotenv').config({
path: path.join(__dirname, '..', `.env.${process.env.NODE_ENV || 'development'}`)
});
}
index.js:
// Must be at the very top
require('./config/env');
const express = require('express');
// ... rest of the app
Environment-Specific Files
.env # Base config (shared defaults)
.env.development # Dev overrides
.env.staging # Staging overrides
.env.production # Production overrides
// Load precedence: .env.{NODE_ENV} overrides .env
const envFile = process.env.NODE_ENV || 'development';
require('dotenv').config({ path: `.env.${envFile}` });
require('dotenv').config({ path: '.env' }); // Fallback
Centralized Config Module
// config/index.js
require('dotenv').config();
const config = {
env: process.env.NODE_ENV || 'development',
server: {
port: parseInt(process.env.PORT) || 3000,
host: process.env.HOST || '0.0.0.0'
},
database: {
mongoUri: process.env.MONGO_URI,
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 5432,
name: process.env.DB_NAME || 'myapp',
user: process.env.DB_USER,
password: process.env.DB_PASSWORD
},
auth: {
jwtSecret: process.env.JWT_SECRET,
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
sessionSecret: process.env.SESSION_SECRET
},
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379'
},
logging: {
level: process.env.LOG_LEVEL || 'info'
},
services: {
stripeKey: process.env.STRIPE_SECRET_KEY,
sendgridKey: process.env.SENDGRID_API_KEY
}
};
module.exports = config;
Config Validation
Validate required config on startup:
npm install joi
// config/validate.js
const Joi = require('joi');
const config = require('./index');
const schema = Joi.object({
env: Joi.string().valid('development', 'staging', 'production').required(),
server: Joi.object({
port: Joi.number().default(3000),
host: Joi.string().default('0.0.0.0')
}),
database: Joi.object({
mongoUri: Joi.string().uri().required(),
host: Joi.string(),
port: Joi.number(),
name: Joi.string(),
user: Joi.string(),
password: Joi.string()
}),
auth: Joi.object({
jwtSecret: Joi.string().min(32).required().error(new Error('JWT_SECRET must be at least 32 characters')),
jwtExpiresIn: Joi.string().default('24h'),
sessionSecret: Joi.string().min(16).required()
}),
redis: Joi.object({
url: Joi.string().uri()
}),
logging: Joi.object({
level: Joi.string().valid('error', 'warn', 'info', 'debug').default('info')
})
});
function validateConfig() {
const { error, value } = schema.validate(config, { abortEarly: false });
if (error) {
console.error('❌ Invalid configuration:');
error.details.forEach(detail => {
console.error(` - ${detail.message}`);
});
process.exit(1);
}
return value;
}
module.exports = { validateConfig };
// index.js - validate on startup
const { validateConfig } = require('./config/validate');
const config = validateConfig();
const express = require('express');
const app = express();
app.listen(config.server.port, () => {
console.log(`Server running in ${config.env} mode on port ${config.server.port}`);
});
Using Config in the App
// routes/users.js
const config = require('../config');
router.get('/export', async (req, res) => {
// Access config values
const dbConfig = config.database;
const users = await User.find();
// Never expose secrets!
res.json({
count: users.length,
environment: config.env,
dbHost: dbConfig.host, // OK - non-sensitive
// Never: dbPassword: dbConfig.password
});
});
.gitignore
# Environment files
.env
.env.*
!.env.example
# Config with secrets
config/secrets.json
.env.example (Commit This)
# Copy to .env and fill in values
NODE_ENV=development
PORT=3000
MONGO_URI=mongodb://localhost:27017/myapp
JWT_SECRET=change-me-to-a-long-random-string
SESSION_SECRET=change-me-too
REDIS_URL=redis://localhost:6379
LOG_LEVEL=debug
Production vs Development Config
// config/index.js - environment-specific overrides
const envSpecific = {
development: {
logging: { level: 'debug' },
database: { name: 'myapp_dev' }
},
staging: {
logging: { level: 'info' },
database: { name: 'myapp_staging' }
},
production: {
logging: { level: 'error' },
database: { name: 'myapp' },
server: { port: process.env.PORT || 8080 }
}
};
const env = process.env.NODE_ENV || 'development';
const config = {
...baseConfig,
...envSpecific[env]
};
Secrets in Production
// Never hardcode secrets
// ❌ Bad
const JWT_SECRET = 'mySuperSecretKey12345';
// ✅ Good - environment variable
const JWT_SECRET = process.env.JWT_SECRET;
// ✅ Platform-specific secret management:
// AWS: AWS Secrets Manager / Parameter Store
// Heroku: Config Vars
// Docker: Docker secrets
// Kubernetes: Secrets
// HashiCorp: Vault
Key Takeaways
- Store all config in environment variables (12-Factor App)
- Use dotenv to load
.envfiles in development - Create a centralized config module for consistent access
- Validate required config on startup - fail fast
- Keep a
.env.examplewith dummy values committed to the repo - Add
.envto.gitignore- never commit real secrets - Use platform-specific secret management in production
- Only log non-sensitive config values