A practical guide to configuration management, stateless processes, and logging in distributed Node.js systems
Introduction
The 12-Factor App methodology has become the gold standard for building scalable, maintainable cloud applications. Created by Heroku’s founders, these principles help developers build applications that are ready for modern deployment environments. While Node.js is theoretically well-suited for this approach, many teams struggle with proper implementation.
In this post, I’ll focus on three critical factors where Node.js teams frequently stumble: configuration management, stateless processes, and logging. For each, I’ll identify common anti-patterns, explain why they’re problematic, and provide practical solutions that my team has successfully implemented in production environments.
Factor III: Configuration – Environment Variables Aren’t Enough
The Common Failures
When it comes to configuration, I frequently see Node.js teams make these mistakes:
- Hardcoding configuration values directly in application code
- Using different configuration mechanisms across environments (e.g., env vars in production, JSON files in development)
- Treating all configuration as equal without considering security implications
- Missing validation for configuration values
- Over-relying on .env files in production environments
Let’s look at a problematic but unfortunately common configuration setup:
// config.js - The problematic approach
const dbConfig = {
development: {
host: 'localhost',
user: 'dev_user',
password: 'dev_password', // Hardcoded credentials 😱
database: 'my_app_dev'
},
production: {
host: process.env.DB_HOST || 'db.example.com',
user: process.env.DB_USER || 'prod_user',
password: process.env.DB_PASSWORD, // Will be undefined if not set
database: process.env.DB_NAME || 'my_app_prod'
}
};
const config = {
port: process.env.PORT || 3000,
dbConfig: dbConfig[process.env.NODE_ENV || 'development'],
apiKeys: {
stripe: process.env.STRIPE_KEY || 'sk_test_123', // Test key in code 😱
aws: process.env.AWS_KEY
}
};
module.exports = config;
What’s wrong with this approach?
- Security vulnerabilities: Sensitive data like API keys and passwords are committed to the repository
- Environment disparity: Development uses a completely different configuration mechanism than production
- No validation: If
DB_PASSWORD
is not set in production, your app will crash when trying to connect - Poor error messages: When configuration is missing, you get cryptic database connection errors rather than clear config errors
The Right Way: Structured Configuration Management
Here’s a better approach using the excellent convict library from Mozilla:
// config.js - The better approach
const convict = require('convict');
const dotenv = require('dotenv');
// Load .env file in development only
if (process.env.NODE_ENV !== 'production') {
dotenv.config();
}
// Define a schema
const config = convict({
env: {
doc: 'The application environment.',
format: ['production', 'development', 'test'],
default: 'development',
env: 'NODE_ENV'
},
port: {
doc: 'The port to bind.',
format: 'port',
default: 3000,
env: 'PORT',
sensitive: false
},
db: {
host: {
doc: 'Database host name/IP',
format: String,
default: 'localhost',
env: 'DB_HOST',
sensitive: false
},
name: {
doc: 'Database name',
format: String,
default: 'my_app_dev',
env: 'DB_NAME',
sensitive: false
},
user: {
doc: 'Database user',
format: String,
default: null,
env: 'DB_USER',
sensitive: false
},
password: {
doc: 'Database password',
format: String,
default: null,
env: 'DB_PASSWORD',
sensitive: true
}
},
apis: {
stripe: {
key: {
doc: 'Stripe API key',
format: String,
default: null,
env: 'STRIPE_KEY',
sensitive: true
}
}
}
});
// Load environment dependent configuration
const env = config.get('env');
try {
config.loadFile(`./config/${env}.json`);
} catch (e) {
console.log('No configuration file found. Using defaults and environment variables.');
}
// Validate
config.validate({ allowed: 'strict' });
module.exports = config;
With separate environment files:
// config/development.json
{
"db": {
"user": "dev_user",
"name": "my_app_dev"
},
"apis": {
"stripe": {
"key": "sk_test_123"
}
}
}
And never committed to the repository:
// config/production.json - NEVER COMMITTED (example only)
{
"apis": {
"stripe": {
"key": "sk_live_xyz"
}
}
}
This approach provides several benefits:
- Validation: Configuration errors appear on startup, not deep in runtime
- Documentation: Each configuration value is documented and typed
- Security: Sensitive values are clearly marked
- Environment consistency: The same mechanism works across all environments
- Default values: Reasonable defaults make development easier
Configuration Best Practices
- Never store secrets in code or config files. Use environment variables or a secure secret management service like AWS Secrets Manager or HashiCorp Vault.
- Validate configuration at startup. Fail fast if required configuration is missing.
- Use different mechanisms for secrets vs. non-sensitive configuration. Not all config needs the same level of protection.
- Consider runtime configuration needs. Some values (like feature flags) might need to change without redeployment.
Factor VI: Processes – The Stateless Illusion
The Common Failures
Node.js applications are often claimed to be stateless but secretly maintain state in ways that break in distributed environments:
- Local memory caching without considering multiple instances
- Local file storage for uploads or temporary files
- In-memory session storage by default (Express’s default MemoryStore)
- Background jobs running directly in the web process
- Websocket connections that must be maintained by specific instances
Here’s a typical problematic implementation:
// app.js - The problematic approach
const express = require('express');
const session = require('express-session');
const app = express();
// In-memory cache that won't be shared across instances
const userCache = {};
// In-memory session store that won't be shared
app.use(session({
secret: 'my-secret',
resave: false,
saveUninitialized: false
// No explicit store means it uses MemoryStore
}));
// Local file uploads
app.post('/upload', (req, res) => {
const fileData = req.body.file;
const fileName = `./uploads/${Date.now()}-${req.body.name}`;
fs.writeFileSync(fileName, fileData);
res.json({ success: true, path: fileName });
});
// Cache that won't be shared across instances
app.get('/users/:id', (req, res) => {
const userId = req.params.id;
if (userCache[userId]) {
return res.json(userCache[userId]);
}
// Fetch from database
database.getUser(userId).then(user => {
// Cache for 1 hour
userCache[userId] = user;
setTimeout(() => { delete userCache[userId]; }, 3600000);
res.json(user);
});
});
// Background job in the web process
setInterval(() => {
database.cleanupOldRecords();
}, 86400000); // Daily cleanup
These practices cause several issues:
- Inconsistent user experience: User A sees different data than User B because they hit different instances with different caches
- Horizontal scaling problems: Adding more instances doesn’t proportionally increase capacity
- Instance affinity requirement: Load balancers need sticky sessions, limiting scaling options
- Data loss: When instances restart or scale down, locally stored files disappear
- Memory leaks: Unbounded local caches grow until instances crash
The Right Way: Truly Stateless Processes
Here’s a better, truly stateless implementation:
// app.js - The better approach
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const multer = require('multer');
const { S3Client } = require('@aws-sdk/client-s3');
const multerS3 = require('multer-s3');
const NodeCache = require('node-cache');
const app = express();
// Redis client for shared state
const redisClient = createClient({
url: process.env.REDIS_URL
});
redisClient.connect().catch(console.error);
// Time-to-live cache with reasonable memory limits
const cache = new NodeCache({
stdTTL: 3600, // 1 hour
checkperiod: 120, // Check for expired items every 2 minutes
maxKeys: 1000 // Limit total cache size
});
// Session store using Redis
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
maxAge: 86400000 // 24 hours
}
}));
// S3 for file storage
const s3 = new S3Client({
region: process.env.AWS_REGION
});
const upload = multer({
storage: multerS3({
s3: s3,
bucket: process.env.S3_BUCKET,
key: function(req, file, cb) {
cb(null, `${Date.now().toString()}-${file.originalname}`);
}
})
});
app.post('/upload', upload.single('file'), (req, res) => {
res.json({
success: true,
path: req.file.location
});
});
// Using local cache with TTL, but for performance only (not correctness)
app.get('/users/:id', async (req, res) => {
const userId = req.params.id;
const cachedUser = cache.get(userId);
if (cachedUser) {
return res.json(cachedUser);
}
try {
const user = await database.getUser(userId);
// Cache with TTL for performance, not correctness
cache.set(userId, user);
res.json(user);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// No background jobs in web process
With a separate worker process for background jobs:
// worker.js
const { createClient } = require('redis');
const { Worker } = require('bullmq');
const redisClient = createClient({
url: process.env.REDIS_URL
});
// Define a worker process separate from the web server
const cleanupWorker = new Worker(
'maintenance',
async job => {
if (job.name === 'cleanup-records') {
await database.cleanupOldRecords();
}
},
{ connection: redisClient }
);
// Handle worker errors
cleanupWorker.on('error', err => {
console.error('Worker error:', err);
});
And job scheduling:
// scheduler.js
const { Queue } = require('bullmq');
const { createClient } = require('redis');
const redisClient = createClient({
url: process.env.REDIS_URL
});
const maintenanceQueue = new Queue('maintenance', { connection: redisClient });
// Schedule daily cleanup job
async function scheduleJobs() {
await maintenanceQueue.add(
'cleanup-records',
{},
{
repeat: {
pattern: '0 0 * * *' // Daily at midnight
}
}
);
}
scheduleJobs().catch(console.error);
This approach provides:
- Shared state: Redis provides a common store for sessions and job queues
- External file storage: S3 ensures files persist regardless of instance lifecycle
- Bounded caching: Local caches have TTL and size limits, used for performance only
- Separate worker processes: Background jobs run in dedicated processes
Statelessness Best Practices
- Treat instances as ephemeral. They can be terminated at any time.
- Externalize all state to databases, caches, or object storage.
- Use local caching carefully and only for performance optimization, not correctness.
- Move background processing to dedicated workers using queues like Bull/BullMQ.
- Design for concurrent requests across multiple instances from the same user.
Factor XI: Logs – Beyond Console.log
The Common Failures
The most common logging issues I see in Node.js applications:
- Using
console.log
directly for production logging - Inconsistent log formats across different parts of the application
- Missing contextual information like request IDs
- Too much logging in production, impacting performance
- Too little structured data making logs difficult to query
A typical problematic implementation:
// Various files across the application
// In auth.js
app.post('/login', (req, res) => {
console.log('Login attempt for user:', req.body.email);
// Authentication logic...
if (error) {
console.error('Login failed:', error);
return res.status(401).json({ error: 'Authentication failed' });
}
console.log('User logged in successfully:', req.body.email);
// Rest of the code...
});
// In orders.js
app.post('/orders', (req, res) => {
// Order creation logic...
try {
const order = await createOrder(req.body);
console.log(`Order #${order.id} created successfully for ${order.amount}`);
res.json(order);
} catch (err) {
console.log('Failed to create order');
console.error(err);
res.status(500).send('Order creation failed');
}
});
Problems with this approach:
- Inconsistent format: Different parts of the code use different logging styles
- Lack of context: No correlation between related log entries
- Limited searchability: Free-form text makes log analysis difficult
- No log levels: Everything is either
log
orerror
- Missing metadata: No timestamps, service names, or environment information
The Right Way: Structured Logging for Distributed Systems
Here’s a better approach using Pino, a low-overhead logging library:
// logger.js
const pino = require('pino');
// Configure log level based on environment
const level = process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug');
// Create the logger
const logger = pino({
level,
timestamp: pino.stdTimeFunctions.isoTime,
base: {
env: process.env.NODE_ENV,
service: process.env.SERVICE_NAME || 'api',
version: process.env.VERSION || '1.0.0'
},
formatters: {
level: (label) => {
return { level: label };
}
},
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined
});
module.exports = logger;
Middleware to add request context:
// request-logger.js
const { v4: uuidv4 } = require('uuid');
const logger = require('./logger');
function requestLogger() {
return (req, res, next) => {
// Generate unique ID for request
const requestId = req.headers['x-request-id'] || uuidv4();
// Add request ID to response headers
res.setHeader('x-request-id', requestId);
// Create request-specific child logger
req.log = logger.child({
requestId,
method: req.method,
url: req.url,
ip: req.ip
});
// Log request start
req.log.info({
msg: 'Request started',
userAgent: req.headers['user-agent']
});
// Record request start time
const startTime = Date.now();
// Log when response completes
res.on('finish', () => {
const responseTime = Date.now() - startTime;
req.log.info({
msg: 'Request completed',
statusCode: res.statusCode,
responseTime
});
});
next();
};
}
module.exports = requestLogger;
Using the logger in route handlers:
// auth.js
const logger = require('./logger');
app.post('/login', (req, res) => {
req.log.debug({ email: req.body.email }, 'Login attempt');
// Authentication logic...
if (error) {
req.log.warn({
email: req.body.email,
error: error.message,
reason: 'Invalid credentials'
}, 'Login failed');
return res.status(401).json({ error: 'Authentication failed' });
}
req.log.info({ userId: user.id }, 'User logged in successfully');
// Rest of the code...
});
// orders.js
app.post('/orders', async (req, res) => {
// Include relevant data fields for debugging/tracing
req.log.debug({
orderData: req.body,
userId: req.user.id
}, 'Creating order');
try {
const order = await createOrder(req.body);
req.log.info({
orderId: order.id,
amount: order.amount,
userId: req.user.id
}, 'Order created successfully');
res.json(order);
} catch (err) {
req.log.error({
err,
orderData: req.body,
userId: req.user.id
}, 'Order creation failed');
res.status(500).send('Order creation failed');
}
});
For production, you’d want to send logs to a centralized system:
// production-logger.js
const pino = require('pino');
const transport = pino.transport({
targets: [
// Local file logs rotated daily
{
target: 'pino/file',
options: { destination: '/var/log/app.log' }
},
// Send to external service
{
target: '@axiomhq/pino', // Or any other compatible transport
options: {
dataset: process.env.AXIOM_DATASET,
token: process.env.AXIOM_TOKEN
}
}
]
});
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: {
env: process.env.NODE_ENV,
service: process.env.SERVICE_NAME,
version: process.env.VERSION
}
}, transport);
module.exports = logger;
This logging strategy provides:
- Structured data: JSON logs that are easily parsed by log management tools
- Context propagation: Request IDs allow tracing requests across services
- Appropriate log levels: Different levels for different types of information
- Performance: Pino is one of the fastest logging libraries for Node.js
- Consistent format: Standard approach used throughout the application
Logging Best Practices
- Log in JSON format for machine readability.
- Include request IDs to trace requests across multiple services.
- Use appropriate log levels consistently.
- Include contextual information but be careful with sensitive data.
- Log at service boundaries (incoming requests, outgoing responses, external API calls).
- Consider log volume in high-traffic services.
Bringing It All Together
When properly implemented, these 12-Factor principles create a Node.js application that:
- Scales horizontally without code changes
- Deploys consistently across different environments
- Recovers gracefully from instance failures
- Provides observability through structured logs
- Maintains security by properly handling configuration
Let’s bring these concepts together with a basic Express application template:
// app.js
const express = require('express');
const helmet = require('helmet');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const convict = require('convict');
const requestLogger = require('./middleware/request-logger');
const logger = require('./utils/logger');
// Load and validate configuration
const config = require('./config');
// Initialize app
const app = express();
// Security headers
app.use(helmet());
// Request parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request logging
app.use(requestLogger());
// Redis client for shared state
const redisClient = createClient({
url: config.get('redis.url')
});
redisClient.connect().catch(err => {
logger.error({ err }, 'Redis connection error');
process.exit(1);
});
// Session with Redis store
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: config.get('session.secret'),
name: 'sessionId',
cookie: {
secure: config.get('env') === 'production',
httpOnly: true,
maxAge: config.get('session.maxAge')
},
resave: false,
saveUninitialized: false
}));
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/users', require('./routes/users'));
app.use('/api/orders', require('./routes/orders'));
// Error handling middleware
app.use((err, req, res, next) => {
req.log.error({ err }, err.message);
res.status(err.status || 500).json({
error: config.get('env') === 'production'
? 'An unexpected error occurred'
: err.message
});
});
// Start server
const port = config.get('port');
const server = app.listen(port, () => {
logger.info({ port }, `Server started on port ${port}`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down gracefully');
server.close(async () => {
logger.info('HTTP server closed');
try {
await redisClient.quit();
logger.info('Redis connection closed');
process.exit(0);
} catch (err) {
logger.error({ err }, 'Error during shutdown');
process.exit(1);
}
});
// Force shutdown if graceful shutdown fails
setTimeout(() => {
logger.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
}, 10000);
});
module.exports = app;
Conclusion: Embracing the 12-Factor Methodology
Building truly cloud-native applications with Node.js requires embracing the 12-Factor methodology fully, not just partially. The three factors we explored—configuration, processes, and logging—are particularly challenging because they involve breaking familiar development patterns.
By implementing proper configuration management, you ensure your application runs consistently across environments while keeping sensitive data secure. By making your processes truly stateless, you enable horizontal scaling and resilience. And by implementing structured logging, you gain the observability needed to operate distributed systems confidently.
These changes may require more upfront investment, but they pay dividends in scalability, reliability, and maintainability—especially as your application and team grow.
Is your Node.js application truly 12-Factor compliant, or are you harboring some of the anti-patterns we’ve discussed? Take the time to review your codebase against these principles. Your future self (and your DevOps team) will thank you.
What 12-Factor principles have you found most challenging to implement in your Node.js applications? Share your experiences in the comments below!
Leave a Reply