Stop Using Environment Variables for Configuration
Environment variables might be causing more problems than they solve. Learn better patterns for application configuration.
Environment variables seem like the perfect solution for application configuration. They're simple, universal, and cloud-native. But they're also causing more problems than they solve.
The Common Pattern
// Looks simple enough...
const config = {
database: {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
},
redis: {
url: process.env.REDIS_URL,
},
api: {
port: parseInt(process.env.API_PORT || '3000'),
key: process.env.API_KEY,
}
};
The Problems
1. Type Safety Issues
// ❌ BAD: Runtime surprises
function connectToDatabase() {
const port = parseInt(process.env.DB_PORT!); // What if it's not a number?
const host = process.env.DB_HOST; // What if it's undefined?
return createConnection(host, port);
}
// ❌ BAD: Type assertions everywhere
const config = {
apiKey: process.env.API_KEY as string,
maxRetries: Number(process.env.MAX_RETRIES) as number,
features: (process.env.ENABLED_FEATURES || '').split(',')
};
2. Validation Nightmares
// ❌ BAD: Scattered validation
if (!process.env.AWS_ACCESS_KEY) {
throw new Error('AWS access key required');
}
if (!process.env.AWS_SECRET_KEY) {
throw new Error('AWS secret key required');
}
if (!process.env.S3_BUCKET) {
throw new Error('S3 bucket required');
}
3. Complex Values
// ❌ BAD: Environment variable limitations
const corsOrigins = process.env.CORS_ORIGINS?.split(',') || [];
const dbPool = parseInt(process.env.DB_POOL_SIZE || '10');
const retryConfig = JSON.parse(process.env.RETRY_CONFIG || '{}');
Better Approach: Configuration Objects
1. Type-Safe Configuration
// ✅ BETTER: Zod schema validation
import { z } from 'zod';
const ConfigSchema = z.object({
database: z.object({
host: z.string().min(1),
port: z.number().int().min(1).max(65535),
user: z.string().min(1),
password: z.string().min(1),
pool: z.object({
min: z.number().int().min(0),
max: z.number().int().min(1),
}),
}),
redis: z.object({
url: z.string().url(),
tls: z.boolean().default(false),
}),
api: z.object({
port: z.number().int().min(1).max(65535),
cors: z.object({
origins: z.array(z.string().url()),
methods: z.array(z.enum(['GET', 'POST', 'PUT', 'DELETE'])),
}),
}),
});
type Config = z.infer<typeof ConfigSchema>;
2. Configuration Loading
// ✅ BETTER: Centralized configuration
class ConfigLoader {
private static instance: Config;
static load(): Config {
if (this.instance) return this.instance;
// Load from different sources
const envConfig = this.loadFromEnv();
const fileConfig = this.loadFromFile();
const secretsConfig = this.loadFromSecrets();
// Merge configurations
const rawConfig = deepMerge(
fileConfig,
envConfig,
secretsConfig
);
// Validate and parse
const config = ConfigSchema.parse(rawConfig);
this.instance = config;
return config;
}
private static loadFromEnv(): Record<string, any> {
return {
database: {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
// ...
},
// ...
};
}
private static loadFromFile(): Record<string, any> {
const env = process.env.NODE_ENV || 'development';
return require(`../config/${env}.json`);
}
private static loadFromSecrets(): Record<string, any> {
// Load from AWS Secrets Manager, Vault, etc.
}
}
3. Usage Pattern
// ✅ BETTER: Type-safe configuration usage
class DatabaseService {
constructor(private readonly config: Config['database']) {}
async connect() {
return createConnection({
host: this.config.host,
port: this.config.port,
pool: {
min: this.config.pool.min,
max: this.config.pool.max,
}
});
}
}
// Application bootstrap
const config = ConfigLoader.load();
const db = new DatabaseService(config.database);
const redis = new RedisService(config.redis);
const api = new ApiServer(config.api);
Better Alternatives
1. Configuration Files
// config/development.json
{
"database": {
"host": "localhost",
"port": 5432,
"pool": {
"min": 1,
"max": 10
}
},
"redis": {
"url": "redis://localhost:6379",
"tls": false
}
}
// config/production.json
{
"database": {
"host": "prod-db.internal",
"port": 5432,
"pool": {
"min": 5,
"max": 50
}
}
}
2. Secrets Management
class SecretsManager {
private static cache: Map<string, string> = new Map();
static async getSecret(name: string): Promise<string> {
if (this.cache.has(name)) {
return this.cache.get(name)!;
}
// Load from AWS Secrets Manager, Vault, etc.
const secret = await this.loadSecret(name);
this.cache.set(name, secret);
return secret;
}
private static async loadSecret(name: string): Promise<string> {
if (process.env.NODE_ENV === 'development') {
return this.loadFromFile(name);
}
return this.loadFromSecretStore(name);
}
}
Best Practices
- Separate Concerns
// Configuration types
interface Config {
app: AppConfig;
services: ServicesConfig;
security: SecurityConfig;
}
// Environment-specific overrides
interface EnvironmentConfig {
env: string;
overrides: Partial<Config>;
}
// Secrets separate from configuration
interface Secrets {
database: DatabaseSecrets;
api: ApiSecrets;
}
- Validation on Startup
class Application {
static async bootstrap(): Promise<void> {
// Load and validate configuration first
const config = await ConfigLoader.load();
// Validate connections and dependencies
await this.validateConnections(config);
// Start application
await this.startServices(config);
}
private static async validateConnections(
config: Config
): Promise<void> {
await Promise.all([
this.checkDatabase(config.database),
this.checkRedis(config.redis),
this.checkExternalServices(config.services)
]);
}
}
Conclusion
Instead of environment variables:
- Use typed configuration objects
- Implement proper validation
- Separate secrets from configuration
- Use appropriate storage for each type of config
- Validate everything at startup
Remember: Configuration is code. Treat it with the same care as your application code.