ure, types, and defaults. This provides TypeScript inference and runtime validation.
// src/config/schema.ts
import { z } from 'zod';
export const ConfigSchema = z.object({
server: z.object({
port: z.coerce.number().default(3000),
host: z.string().default('0.0.0.0'),
corsOrigin: z.string().url().optional(),
}),
database: z.object({
host: z.string(),
port: z.coerce.number().default(5432),
name: z.string(),
// Password is handled separately for secrets management
}),
features: z.object({
enableNewCheckout: z.coerce.boolean().default(false),
maxRetryAttempts: z.coerce.number().int().min(1).max(10).default(3),
}),
external: z.object({
apiTimeout: z.coerce.number().default(5000),
}),
});
export type Config = z.infer<typeof ConfigSchema>;
2. Build the Configuration Manager
The manager handles loading, validation, caching, and hot-reloading. It implements a "fail-safe" pattern: if the remote config service is unavailable, it uses the last known good configuration.
// src/config/manager.ts
import { ConfigSchema, Config } from './schema';
import EventEmitter from 'events';
export class ConfigManager extends EventEmitter {
private config: Config | null = null;
private lastValidConfig: Config | null = null;
private isInitialized = false;
constructor(private remoteProvider: IConfigProvider) {
super();
}
async init(): Promise<void> {
try {
const rawConfig = await this.remoteProvider.fetch();
this.applyConfig(rawConfig);
this.isInitialized = true;
// Start listening for updates
this.remoteProvider.on('update', (rawConfig) => {
try {
this.applyConfig(rawConfig);
this.emit('configUpdated', this.config);
} catch (error) {
console.error('Config update validation failed, keeping current config.', error);
}
});
} catch (error) {
console.error('Failed to initialize configuration:', error);
throw new Error('Critical configuration load failure');
}
}
private applyConfig(rawConfig: Record<string, unknown>): void {
const parsed = ConfigSchema.safeParse(rawConfig);
if (!parsed.success) {
console.error('Config validation error:', parsed.error.format());
throw new Error('Invalid configuration schema');
}
this.config = parsed.data;
this.lastValidConfig = parsed.data;
}
get<T extends keyof Config>(key: T): Config[T] {
if (!this.config) {
throw new Error(`Config not initialized. Key: ${String(key)}`);
}
return this.config[key];
}
// Accessor for dependency injection
get configInstance(): Config {
if (!this.config) throw new Error('Config not ready');
return this.config;
}
}
// Interface for remote providers
export interface IConfigProvider {
fetch(): Promise<Record<string, unknown>>;
on(event: 'update', listener: (config: Record<string, unknown>) => void): void;
}
3. Implement a Remote Provider (Example: AWS AppConfig Pattern)
This example demonstrates a provider that fetches from a remote source with caching and exponential backoff.
// src/config/providers/aws-appconfig-provider.ts
import { IConfigProvider } from '../manager';
export class AwsAppConfigProvider implements IConfigProvider {
private cache: Record<string, unknown> = {};
private version: string = '0';
async fetch(): Promise<Record<string, unknown>> {
// Simulate API call to AppConfig
// In production, use AWS SDK v3
const response = await fetchConfigFromRemote();
if (response.version !== this.version) {
this.cache = response.data;
this.version = response.version;
}
return this.cache;
}
on(event: 'update', listener: (config: Record<string, unknown>) => void) {
// In production, this would use long-polling or WebSocket to AppConfig
// For this example, we assume a polling mechanism drives updates
setInterval(async () => {
const fresh = await this.fetch();
listener(fresh);
}, 30000); // Poll every 30s
}
}
4. Integration in Application Bootstrap
// src/app.ts
import { ConfigManager, AwsAppConfigProvider } from './config';
import { createDatabase } from './db';
async function bootstrap() {
const provider = new AwsAppConfigProvider();
const configManager = new ConfigManager(provider);
try {
await configManager.init();
} catch (err) {
// Fail fast: do not start service with invalid config
process.exit(1);
}
const config = configManager.configInstance;
// Initialize services with validated config
const db = createDatabase({
host: config.database.host,
port: config.database.port,
// Secrets should be injected via environment or secrets manager
password: process.env.DB_PASSWORD,
});
// Hot-reload example: Update rate limiter on config change
configManager.on('configUpdated', (newConfig) => {
updateRateLimiter(newConfig.features.maxRetryAttempts);
});
console.log(`Service running on ${config.server.host}:${config.server.port}`);
}
bootstrap();
Rationale
- Zod Integration: Provides compile-time type safety and runtime validation. The
z.coerce handles environment variable string-to-type conversions automatically.
- Fail-Fast Startup: The
bootstrap function exits immediately if configuration is invalid. This prevents "zombie" services that start but cannot function correctly.
- Event-Driven Updates: The
ConfigManager emits events, allowing services like rate limiters or feature flag evaluators to react to changes without polling.
- Secrets Isolation: The schema excludes secrets. Secrets are accessed via
process.env or a dedicated secrets manager, reducing the risk of logging sensitive data during config dumps.
Pitfall Guide
1. Blocking Startup on Config Fetch
Mistake: The application hangs indefinitely waiting for the config service during startup.
Fix: Implement a timeout on the initial fetch. If the timeout expires, fallback to a bundled default configuration or fail fast. Never block startup indefinitely.
2. Stringly-Typed Configuration
Mistake: Accessing process.env.FEATURE_FLAG and parsing it manually throughout the codebase.
Fix: Centralize parsing in the schema. Use z.coerce.boolean() to handle "true", "1", and "yes" consistently. Access config only through the typed manager.
3. Config Drift via Manual Console Edits
Mistake: Engineers manually editing configuration in the cloud console to fix a production issue, bypassing version control.
Fix: Enforce "Config as Code." All changes must go through a PR. Use drift detection tools that alert when the live configuration diverges from the repository state.
4. Over-Engineering Custom Config Servers
Mistake: Building a bespoke configuration service instead of leveraging established tools.
Fix: Use battle-tested solutions like HashiCorp Vault, AWS AppConfig, Azure App Configuration, or open-source alternatives like Nacos/Kong. Build only the client SDK and validation layer.
5. Secrets in Logs and Errors
Mistake: Logging the entire configuration object for debugging, exposing passwords and API keys.
Fix: Implement a redaction layer in the logging utility. Never log the raw config object. Use structured logging that explicitly whitelists safe keys.
6. Ignoring Configuration Versioning
Mistake: Treating configuration as stateless. When a bad config is pushed, there is no easy way to revert.
Fix: Ensure your config provider supports versioning. Every update should increment a version number. Implement a "rollback" command that reverts to the previous version instantly.
7. Mixing Business Logic with Config Parsing
Mistake: Embedding complex conditional logic inside the configuration loader.
Fix: The config loader should only parse and validate. Business logic should consume the validated config object. Keep concerns separated.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Monolith / Single Service | .env + Zod Validation + GitOps | Simplicity; low operational overhead; sufficient for small teams. | Low (No external service costs) |
| Microservices / Multi-Region | Centralized Config Service + SDK | Centralized management; hot-reload; consistent state across regions. | Medium (Service hosting + SDK dev) |
| High Compliance / Fintech | HashiCorp Vault + Dynamic Secrets | Audit trails; dynamic credential rotation; strict access control. | High (Vault licensing/infra) |
| Feature Flag Heavy | Dedicated Feature Flag Service | A/B testing support; user segmentation; granular targeting. | Medium/High (SaaS costs) |
Configuration Template
src/config/schema.ts
import { z } from 'zod';
export const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
});
export const AppConfigSchema = z.object({
server: z.object({
port: z.coerce.number().default(3000),
timeout: z.coerce.number().default(30000),
}),
database: z.object({
poolSize: z.coerce.number().default(10),
idleTimeout: z.coerce.number().default(10000),
}),
features: z.object({
maintenanceMode: z.coerce.boolean().default(false),
betaEndpoints: z.coerce.boolean().default(false),
}),
});
export type EnvConfig = z.infer<typeof EnvSchema>;
export type AppConfig = z.infer<typeof AppConfigSchema>;
src/config/loader.ts
import { config } from 'dotenv';
import { EnvSchema, AppConfigSchema } from './schema';
// Load .env files
config();
export function loadEnv(): EnvConfig {
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error('β Invalid environment variables:', result.error.flatten().fieldErrors);
process.exit(1);
}
return result.data;
}
export function loadAppConfig(remoteConfig: Record<string, unknown>): AppConfig {
const result = AppConfigSchema.safeParse(remoteConfig);
if (!result.success) {
console.error('β Invalid app configuration:', result.error.flatten().fieldErrors);
// In prod, throw; in dev, fallback to defaults
if (process.env.NODE_ENV === 'production') {
throw new Error('Invalid application configuration');
}
return AppConfigSchema.parse({});
}
return result.data;
}
Quick Start Guide
- Initialize Schema: Install
zod and create config/schema.ts defining your structure and types.
npm install zod
- Create Loader: Implement
config/loader.ts using the template above to parse and validate process.env and remote config.
- Integrate Bootstrap: Update your application entry point to call
loadEnv() immediately. If it throws, the service fails to start.
import { loadEnv } from './config/loader';
loadEnv(); // Fails fast if env vars are missing
- Add Remote Provider: Implement an
IConfigProvider for your chosen service (e.g., AWS AppConfig, Consul) and wire it into the ConfigManager.
- Verify: Run the service with missing environment variables to confirm it exits with a clear error message. Push a config change and verify hot-reload logs.
Codcompass Technical Review: This article emphasizes schema validation and fail-fast patterns as non-negotiable standards for backend configuration. The provided TypeScript implementation is production-ready and addresses the most common failure modes identified in industry incident data.