Express Configuration Management
Configuration management is a critical aspect of building robust Express.js applications. Proper configuration handling ensures your application can adapt to different environments (development, testing, production), keeps sensitive data secure, and makes your code more maintainable.
Introduction
When developing Express applications, you'll need to manage various configuration settings:
- Database connection strings
- API keys and secrets
- Environment-specific behavior
- Server ports and hosts
- Feature flags
- Logging levels
Without a proper configuration strategy, you might end up with hardcoded values, security vulnerabilities, or difficulty deploying your application to different environments. This guide will walk you through best practices for managing configurations in Express applications.
Configuration Basics
Environment Variables
Environment variables are a fundamental way to manage configuration. They separate configuration from code and allow different settings in different environments.
Node.js provides access to environment variables through the process.env
object:
const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL || 'mongodb://localhost:27017/myapp';
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
This approach allows you to set different values for PORT
and DATABASE_URL
in different environments without changing the code.
Using dotenv
The dotenv
package is a popular solution for loading environment variables from a .env
file into process.env
. This is especially useful in development environments.
First, install dotenv
:
npm install dotenv
Create a .env
file in your project root:
PORT=3000
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=my_super_secret_key
NODE_ENV=development
Then load these variables early in your application:
// At the very top of your app.js or index.js
require('dotenv').config();
const express = require('express');
const app = express();
// Now you can use process.env
const port = process.env.PORT || 3000;
// Rest of your application code
Never commit your .env
files to version control. Always add them to your .gitignore
file to prevent exposing sensitive information.
Create a .env.example
file with the structure but without actual values to help other developers understand what variables are needed:
PORT=
DATABASE_URL=
JWT_SECRET=
NODE_ENV=
Creating a Configuration Module
A dedicated configuration module can help organize and centralize your application's configuration settings.
Basic Configuration Module
// config.js
require('dotenv').config();
const config = {
env: process.env.NODE_ENV || 'development',
port: process.env.PORT || 3000,
db: {
url: process.env.DATABASE_URL || 'mongodb://localhost:27017/myapp',
},
jwt: {
secret: process.env.JWT_SECRET || 'default_dev_secret',
expiresIn: process.env.JWT_EXPIRES_IN || '1d',
},
logging: {
level: process.env.LOG_LEVEL || 'info',
}
};
module.exports = config;
Then use it in your application:
// app.js
const express = require('express');
const config = require('./config');
const app = express();
// Use the configuration values
app.listen(config.port, () => {
console.log(`Server running on port ${config.port} in ${config.env} mode`);
});
Environment-Specific Configuration
For more complex applications, you may want to have different configurations based on the environment:
// config.js
require('dotenv').config();
const env = process.env.NODE_ENV || 'development';
const baseConfig = {
env,
port: process.env.PORT || 3000,
isDevelopment: env === 'development',
isProduction: env === 'production',
isTest: env === 'test',
};
const envConfig = {
development: {
db: {
url: process.env.DEV_DATABASE_URL || 'mongodb://localhost:27017/myapp_dev',
},
logging: {
level: 'debug',
}
},
production: {
db: {
url: process.env.PROD_DATABASE_URL,
},
logging: {
level: 'error',
}
},
test: {
db: {
url: process.env.TEST_DATABASE_URL || 'mongodb://localhost:27017/myapp_test',
},
logging: {
level: 'none',
}
}
};
module.exports = { ...baseConfig, ...envConfig[env] };
Configuration Validation
It's important to validate your configuration at startup to catch missing or invalid values early:
// config/validate.js
const Joi = require('joi');
function validateConfig(config) {
const schema = Joi.object({
port: Joi.number().default(3000),
env: Joi.string().valid('development', 'production', 'test').required(),
db: Joi.object({
url: Joi.string().uri().required()
}).required(),
jwt: Joi.object({
secret: Joi.string().min(8).required(),
expiresIn: Joi.string().required()
}).required()
});
const { error, value } = schema.validate(config);
if (error) {
throw new Error(`Configuration validation error: ${error.message}`);
}
return value;
}
module.exports = validateConfig;
Use the validation in your config file:
// config.js
require('dotenv').config();
const validateConfig = require('./config/validate');
const rawConfig = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT, 10) || 3000,
db: {
url: process.env.DATABASE_URL,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '1d',
}
};
// This will throw an error if the configuration is invalid
const config = validateConfig(rawConfig);
module.exports = config;
Real-World Example: Complete Express Application
Let's see how configuration management fits into a complete Express application structure:
project-root/
├── .env # Environment variables (not committed)
├── .env.example # Example environment variables (committed)
├── package.json
├── src/
│ ├── index.js # Application entry point
│ ├── app.js # Express app setup
│ ├── config/
│ │ ├── index.js # Main configuration module
│ │ ├── database.js # Database configuration
│ │ └── validate.js # Configuration validation
│ ├── routes/
│ │ └── ...
│ ├── controllers/
│ │ └── ...
│ └── models/
│ └── ...
└── tests/
└── ...
Configuration module (src/config/index.js):
require('dotenv').config();
const validateConfig = require('./validate');
const env = process.env.NODE_ENV || 'development';
// Base configuration
const config = {
env,
name: process.env.APP_NAME || 'express-app',
host: process.env.HOST || 'localhost',
port: parseInt(process.env.PORT, 10) || 3000,
isDevelopment: env === 'development',
isProduction: env === 'production',
isTest: env === 'test',
};
// Environment-specific configuration
const envConfigs = {
development: {
db: require('./database').development,
jwt: {
secret: process.env.JWT_SECRET || 'dev_secret',
expiresIn: '1d',
},
logging: {
level: 'debug',
},
cors: {
origin: 'http://localhost:3000',
}
},
production: {
db: require('./database').production,
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: '7d',
},
logging: {
level: 'error',
},
cors: {
origin: process.env.CORS_ORIGIN || 'https://myapp.com',
}
},
test: {
db: require('./database').test,
jwt: {
secret: 'test_secret',
expiresIn: '1h',
},
logging: {
level: 'none',
},
cors: {
origin: '*',
}
}
};
// Merge the base config with the environment-specific config
const mergedConfig = { ...config, ...envConfigs[env] };
// Validate the configuration
const validatedConfig = validateConfig(mergedConfig);
module.exports = validatedConfig;
Database configuration (src/config/database.js):
module.exports = {
development: {
url: process.env.DEV_DATABASE_URL || 'mongodb://localhost:27017/myapp_dev',
options: {
useNewUrlParser: true,
useUnifiedTopology: true,
}
},
production: {
url: process.env.PROD_DATABASE_URL,
options: {
useNewUrlParser: true,
useUnifiedTopology: true,
poolSize: 10,
}
},
test: {
url: process.env.TEST_DATABASE_URL || 'mongodb://localhost:27017/myapp_test',
options: {
useNewUrlParser: true,
useUnifiedTopology: true,
}
}
};
Application entry point (src/index.js):
const app = require('./app');
const config = require('./config');
const mongoose = require('mongoose');
// Connect to database
mongoose.connect(config.db.url, config.db.options)
.then(() => {
console.log('Connected to database');
// Start server
app.listen(config.port, () => {
console.log(`${config.name} is running on port ${config.port} in ${config.env} mode`);
});
})
.catch(err => {
console.error('Failed to connect to database:', err);
process.exit(1);
});
Express application setup (src/app.js):
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const helmet = require('helmet');
const config = require('./config');
const app = express();
// Apply middleware based on configuration
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Security middleware
app.use(helmet());
// CORS setup from configuration
app.use(cors(config.cors));
// Logging based on environment
if (!config.isTest) {
app.use(morgan(config.isDevelopment ? 'dev' : 'combined'));
}
// Routes
app.use('/api/users', require('./routes/users'));
app.use('/api/auth', require('./routes/auth'));
// Error handler
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: {
message: err.message,
...(config.isDevelopment && { stack: err.stack }),
}
});
});
module.exports = app;
Securely Handling Secrets
For sensitive configuration like API keys and database passwords:
- Never commit secrets to version control
- Use environment variables for all sensitive data
- Consider using a secrets manager for production (AWS Secrets Manager, HashiCorp Vault)
- Implement encryption for sensitive configuration when necessary
For local development, you can use a .env
file, but for production, it's better to use platform-specific environment variable mechanisms:
- Heroku Config Vars
- AWS Parameter Store
- Docker environment variables
- Kubernetes Secrets
Configuration Best Practices
- Separate configuration from code: Never hardcode configuration values
- Use environment variables: Especially for sensitive data
- Validate configuration: Check that all required values are present at startup
- Default wisely: Provide sensible defaults for non-sensitive values
- Centralize configuration: Use a dedicated configuration module
- Use hierarchical configuration: Base config + environment-specific overrides
- Document configuration: Include a
.env.example
file - Fail fast: If a critical configuration is missing, exit immediately
- Use type conversion: Convert string environment variables to appropriate types
- Keep it simple: Don't over-engineer configuration for small applications
Summary
Proper configuration management is essential for building maintainable, secure, and adaptable Express applications. By following these best practices:
- You separate configuration from code
- Your application can run in different environments
- You protect sensitive data from exposure
- Your application is easier to deploy and maintain
- You can validate configuration to catch issues early
By implementing a centralized configuration module and validating your configuration values, you set the foundation for a robust application architecture.
Additional Resources
- dotenv documentation
- 12-Factor App: Config
- Joi validation library
- Node.js environment variables best practices
- Managing environment variables in Node.js
Exercises
- Create a basic Express application with a configuration module that supports development, test, and production environments.
- Add configuration validation using Joi to ensure required values are present.
- Implement a feature flag system using configuration (e.g., enable/disable certain API endpoints based on environment).
- Create a script that generates a
.env
file with random secure values for secrets. - Extend the configuration module to support loading from a JSON file in addition to environment variables.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)