Loading content...
A core principle of modern software delivery is that the same build artifact runs in all environments. The difference between development, staging, and production isn't the code—it's the configuration. Database URLs change. API endpoints switch. Feature flags toggle. Security settings tighten.
This principle, central to twelve-factor apps and continuous delivery, separates concerns beautifully: code is versioned in source control, while configuration is managed per environment. But achieving this separation cleanly requires careful design.
Environment-based configuration done poorly leads to configuration sprawl, inconsistent environments, and the dreaded 'works in staging, breaks in production.' Done well, it creates predictable, debuggable deployments where environment differences are explicit and minimal.
By the end of this page, you will understand how to design configuration systems that adapt across environments while maximizing consistency. You'll learn environment detection, profile systems, environment variable conventions, and techniques for minimizing environment-specific divergence.
Before designing environment-based configuration, understand what environments exist and why they differ. Each environment serves distinct purposes, requiring specific configuration characteristics.
Common deployment environments:
| Environment | Purpose | Configuration Characteristics |
|---|---|---|
| Development (Local) | Individual developer testing | Local services, debug logging, mock integrations, no SSL |
| CI/Test | Automated testing in pipelines | Ephemeral databases, test fixtures, fast timeouts |
| Staging/QA | Pre-production verification | Production-like, sanitized data, internal access only |
| Production | Serving real users | Real databases, minimal logging, full security, monitoring |
| Disaster Recovery | Failover capability | Mirrors production, different region, standby mode |
What should differ between environments:
Not everything should vary. Identify the minimal set of differences necessary:
Every environment difference is a potential source of 'works here, fails there' bugs. Strive to minimize differences to only what's absolutely necessary. The more staging resembles production, the more reliable your deployments become.
How does your application know which environment it's running in? This seemingly simple question has important implications for configuration loading.
Common detection approaches:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Pattern 1: Environment variable (most common)type Environment = "development" | "test" | "staging" | "production"; function detectEnvironment(): Environment { const env = process.env.NODE_ENV || process.env.APP_ENV; switch (env?.toLowerCase()) { case "production": case "prod": return "production"; case "staging": case "stage": case "qa": return "staging"; case "test": case "ci": return "test"; default: return "development"; // Safe default for local work }} // Pattern 2: Domain/hostname detection (for web apps)function detectFromHostname(): Environment { const hostname = process.env.HOSTNAME || os.hostname(); if (hostname.includes("prod") || hostname.endsWith(".com")) { return "production"; } if (hostname.includes("staging") || hostname.includes("stage")) { return "staging"; } return "development";} // Pattern 3: Configuration profile (explicit selection)interface ConfigProfile { name: string; environment: Environment; configPath: string;} const PROFILES: Record<string, ConfigProfile> = { "dev": { name: "Development", environment: "development", configPath: "./config/dev.yaml" }, "test": { name: "Test", environment: "test", configPath: "./config/test.yaml" }, "staging": { name: "Staging", environment: "staging", configPath: "./config/staging.yaml" }, "prod": { name: "Production", environment: "production", configPath: "./config/prod.yaml" },}; function loadProfile(): ConfigProfile { const profileName = process.env.CONFIG_PROFILE || "dev"; const profile = PROFILES[profileName]; if (!profile) { throw new ConfigurationError( `Unknown config profile: ${profileName}. Valid: ${Object.keys(PROFILES).join(", ")}` ); } return profile;}Environment variables are the primary mechanism for environment-specific configuration in containerized and cloud-native deployments. Following consistent conventions makes configuration predictable and discoverable.
Naming conventions:
123456789101112131415161718192021222324252627282930
# Pattern: {APP_PREFIX}_{SECTION}_{SETTING} # Application identificationAPP_NAME=myserviceAPP_VERSION=1.2.3APP_ENV=production # Database configurationAPP_DATABASE_HOST=db.production.internalAPP_DATABASE_PORT=5432APP_DATABASE_NAME=myserviceAPP_DATABASE_USERNAME=app_userAPP_DATABASE_PASSWORD=<from-secrets-manager>APP_DATABASE_POOL_SIZE=20APP_DATABASE_CONNECTION_TIMEOUT_MS=30000 # HTTP server configurationAPP_SERVER_HTTP_PORT=8080APP_SERVER_HTTP_HOST=0.0.0.0APP_SERVER_HTTP_REQUEST_TIMEOUT_MS=60000 # Feature flagsAPP_FEATURE_NEW_CHECKOUT=trueAPP_FEATURE_DARK_MODE=falseAPP_FEATURE_BETA_API=true # External servicesAPP_PAYMENTS_API_URL=https://api.payments.com/v2APP_PAYMENTS_API_TIMEOUT_MS=10000APP_ANALYTICS_ENDPOINT=https://analytics.internal/ingestMapping environment variables to configuration:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// Type-safe environment variable readerclass EnvReader { constructor(private prefix: string = "APP") {} getString(key: string, required: boolean = true): string | undefined { const envKey = `${this.prefix}_${key}`; const value = process.env[envKey]; if (required && !value) { throw new ConfigurationError(`Required env var missing: ${envKey}`); } return value; } getNumber(key: string, defaultValue?: number): number { const value = this.getString(key, defaultValue === undefined); if (!value) return defaultValue!; const num = parseInt(value, 10); if (isNaN(num)) { throw new ConfigurationError(`Invalid number for ${key}: ${value}`); } return num; } getBoolean(key: string, defaultValue?: boolean): boolean { const value = this.getString(key, defaultValue === undefined); if (!value) return defaultValue!; const lower = value.toLowerCase(); if (lower === "true" || lower === "1" || lower === "yes") return true; if (lower === "false" || lower === "0" || lower === "no") return false; throw new ConfigurationError(`Invalid boolean for ${key}: ${value}`); } getEnum<T extends string>(key: string, validValues: readonly T[]): T { const value = this.getString(key) as T; if (!validValues.includes(value)) { throw new ConfigurationError( `Invalid value for ${key}: ${value}. Valid: ${validValues.join(", ")}` ); } return value; }} // Usageconst env = new EnvReader("APP"); const databaseConfig: DatabaseConfig = { host: env.getString("DATABASE_HOST")!, port: env.getNumber("DATABASE_PORT", 5432), database: env.getString("DATABASE_NAME")!, username: env.getString("DATABASE_USERNAME")!, password: env.getString("DATABASE_PASSWORD")!, poolSize: env.getNumber("DATABASE_POOL_SIZE", 10), connectionTimeoutMs: env.getNumber("DATABASE_CONNECTION_TIMEOUT_MS", 30000),};Generate documentation of all expected environment variables from your configuration code. This prevents drift between what the code expects and what operators know to configure. Some tools can generate .env.example files automatically from TypeScript configuration interfaces.
A configuration profile is a named set of configuration overrides. Profiles allow loading different configuration bundles without changing individual environment variables—useful for testing specific scenarios or maintaining multiple deployment variants.
Profile-based configuration loading:
123456789101112131415161718192021222324252627282930313233343536373839404142
// config/// ├── base.yaml <- Shared across all profiles// ├── profiles/// │ ├── development.yaml// │ ├── test.yaml// │ ├── staging.yaml// │ ├── production.yaml// │ └── performance-test.yaml <- Special-purpose profile// └── local.yaml <- Local overrides (gitignored) interface ConfigurationLoader { loadProfile(profileName: string): Promise<AppConfig>;} class YamlConfigurationLoader implements ConfigurationLoader { async loadProfile(profileName: string): Promise<AppConfig> { // 1. Load base configuration const base = await this.loadYaml("config/base.yaml"); // 2. Load profile-specific overrides const profilePath = `config/profiles/${profileName}.yaml`; const profile = await this.loadYaml(profilePath); // 3. Load local overrides if present (for developer customization) const local = await this.loadYamlOptional("config/local.yaml"); // 4. Merge in order: base < profile < local const merged = this.deepMerge(base, profile, local); // 5. Apply environment variable overrides (highest precedence) const withEnv = this.applyEnvironmentOverrides(merged); // 6. Validate and freeze return this.validateAndFreeze(withEnv); }} // Profile switching at runtimeconst profile = process.env.CONFIG_PROFILE || "development";const config = await loader.loadProfile(profile); console.log(`Loaded configuration profile: ${profile}`);Profile inheritance and composition:
Profiles can inherit from each other, reducing duplication:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
# config/profiles/base-cloud.yaml# Shared configuration for all cloud environmentsaws: region: us-east-1 server: http: port: 8080 host: "0.0.0.0" database: pool: minConnections: 5 maxConnections: 20 logging: format: json ---# config/profiles/staging.yamlextends: base-cloud environment: staging database: host: staging-db.internal features: enableBetaFeatures: true enableDebugEndpoints: true ---# config/profiles/production.yaml extends: base-cloud environment: production database: host: prod-db.internal pool: maxConnections: 50 # Override base-cloud features: enableBetaFeatures: false enableDebugEndpoints: false logging: level: info # Less verbose in productionUse profiles for structured, related configuration changes (like switching from local dev to cloud staging). Use environment variables for individual values that may differ even within the same profile (like database passwords, which differ per-deployment even in production).
Secrets—passwords, API keys, tokens—require special handling. Unlike regular configuration, secrets must be protected from exposure in logs, version control, and error messages.
Environment-specific secret management:
| Environment | Recommended Approach | Example |
|---|---|---|
| Development | Local .env file (gitignored) | DB_PASSWORD=devpassword |
| CI/Test | Pipeline secret injection | GitHub Secrets, GitLab CI Variables |
| Staging | Secrets manager with limited access | AWS Secrets Manager, HashiCorp Vault |
| Production | Secrets manager with audit logging | AWS Secrets Manager + CloudTrail, Vault + Audit |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// Don't store secrets in configuration objects - store referencesinterface DatabaseConfig { host: string; port: number; database: string; // Reference to secret, not the secret itself credentialsRef: SecretReference;} interface SecretReference { source: "env" | "file" | "vault" | "aws-secrets-manager"; key: string;} // Secret resolver that fetches actual values at runtimeinterface SecretsResolver { resolve(ref: SecretReference): Promise<string>;} class CompositeSecretsResolver implements SecretsResolver { constructor(private resolvers: Map<string, SecretsResolver>) {} async resolve(ref: SecretReference): Promise<string> { const resolver = this.resolvers.get(ref.source); if (!resolver) { throw new ConfigurationError(`Unknown secret source: ${ref.source}`); } return resolver.resolve(ref); }} // Environment-specific resolver configurationfunction createSecretsResolver(environment: Environment): SecretsResolver { const resolvers = new Map<string, SecretsResolver>(); // All environments can use environment variables resolvers.set("env", new EnvVarSecretsResolver()); if (environment === "development") { // Dev can use local files resolvers.set("file", new FileSecretsResolver("./secrets")); } else { // Non-dev uses secrets manager resolvers.set("aws-secrets-manager", new AwsSecretsManagerResolver()); resolvers.set("vault", new VaultSecretsResolver()); } return new CompositeSecretsResolver(resolvers);} // Usage at application startupconst secretsResolver = createSecretsResolver(environment);const dbPassword = await secretsResolver.resolve(config.database.credentialsRef);Never log configuration that might contain secrets. When logging configuration at startup, redact or omit any fields that contain sensitive data. Better yet, don't put secrets in the config object at all—keep them in a separate SecretsResolver that provides values on demand.
Environment parity means keeping development, staging, and production as similar as possible. Differences cause bugs that only appear in production—the worst kind of bugs.
Sources of environment drift:
Strategies for maintaining parity:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// Strategy 1: Use the same backing services everywhere// Docker Compose for local development matches production stack // docker-compose.dev.yml// services:// postgres:// image: postgres:15 # Same version as production// redis:// image: redis:7 # Same version as production// localstack:// image: localstack/localstack # AWS services locally // Strategy 2: Minimize environment-specific code pathsenum Environment { Development = "development", Staging = "staging", Production = "production",} // ❌ BAD: Environment-specific logic scattered in business codefunction processPayment(payment: Payment): Result { if (environment === Environment.Development) { // Completely different code path in dev return mockPaymentProcessor.process(payment); } else { return realPaymentProcessor.process(payment); }} // ✅ GOOD: Same code path, environment-specific configuration/dependenciesinterface PaymentProcessor { process(payment: Payment): Promise<Result>;} // Injected at startup based on environmentclass PaymentService { constructor(private processor: PaymentProcessor) {} process(payment: Payment): Promise<Result> { // Same code path in all environments return this.processor.process(payment); }} // Strategy 3: Configuration that fails in non-production// If a production-only integration isn't configured, fail earlyfunction validateProductionReadiness(config: AppConfig): void { if (config.environment === "production") { if (!config.monitoring.datadogApiKey) { throw new ConfigurationError( "Production requires monitoring configuration" ); } if (!config.alerting.pagerDutyServiceKey) { throw new ConfigurationError( "Production requires alerting configuration" ); } }}Invest in making local development production-like. Docker Compose, local Kubernetes (Kind, Minikube), or cloud-based dev environments (Gitpod, Codespaces) can run the full production stack locally. The closer local development matches production, the fewer surprises at deployment time.
Different environments have different configuration requirements. Production has stricter requirements than development—SSL must be enabled, logging must be configured, monitoring must be present.
Tiered validation approach:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// Validation rules by environmentinterface ConfigValidationRule { description: string; environments: Environment[]; validate: (config: AppConfig) => ValidationResult;} const VALIDATION_RULES: ConfigValidationRule[] = [ // Rules for all environments { description: "Database connection must be configured", environments: ["development", "staging", "production"], validate: (config) => { if (!config.database.host) { return { valid: false, error: "Database host is required" }; } return { valid: true }; }, }, // Production-only rules { description: "SSL must be enabled in production", environments: ["production"], validate: (config) => { if (!config.server.tls?.enabled) { return { valid: false, error: "TLS must be enabled in production" }; } return { valid: true }; }, }, { description: "Debug logging forbidden in production", environments: ["production"], validate: (config) => { if (config.logging.level === "debug" || config.logging.level === "trace") { return { valid: false, error: "Debug/trace logging not allowed in production" }; } return { valid: true }; }, }, { description: "Monitoring must be configured in production", environments: ["production"], validate: (config) => { if (!config.monitoring.enabled || !config.monitoring.endpoint) { return { valid: false, error: "Monitoring is required in production" }; } return { valid: true }; }, }, // Staging + Production rules { description: "Connection pooling must be enabled", environments: ["staging", "production"], validate: (config) => { if (config.database.pool.maxConnections < 5) { return { valid: false, error: "Pool size too small for staging/production" }; } return { valid: true }; }, }, // Development warnings (non-blocking) { description: "Consider enabling SSL even in development", environments: ["development"], validate: (config) => { if (!config.server.tls?.enabled) { return { valid: true, warning: "Consider enabling TLS in development for parity" }; } return { valid: true }; }, },]; function validateConfiguration( config: AppConfig, environment: Environment): ConfigValidationResult { const errors: string[] = []; const warnings: string[] = []; for (const rule of VALIDATION_RULES) { if (!rule.environments.includes(environment)) continue; const result = rule.validate(config); if (!result.valid && result.error) { errors.push(`${rule.description}: ${result.error}`); } if (result.warning) { warnings.push(`${rule.description}: ${result.warning}`); } } return { valid: errors.length === 0, errors, warnings, };}In production, configuration validation failures should prevent application startup—it's better to not start than to start with invalid configuration. In development, you might allow warnings or graceful degradation to avoid blocking developer productivity.
Configuration code requires testing like any other code. Test that configuration loads correctly, validates properly, and merges as expected across environments.
Configuration testing strategies:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
// Test 1: All environment configurations load successfullydescribe("Configuration Loading", () => { const environments = ["development", "test", "staging", "production"]; for (const env of environments) { it(`should load ${env} configuration without errors`, async () => { // Set minimal required environment variables process.env.APP_ENV = env; process.env.DATABASE_PASSWORD = "test-password"; const config = await loadConfiguration(); expect(config).toBeDefined(); expect(config.environment).toBe(env); }); }}); // Test 2: Configuration validation catches issuesdescribe("Configuration Validation", () => { it("should reject production config without TLS", () => { const config: AppConfig = { environment: "production", server: { tls: { enabled: false } }, // ... other config }; const result = validateConfiguration(config, "production"); expect(result.valid).toBe(false); expect(result.errors).toContain(expect.stringContaining("TLS")); }); it("should allow development config without TLS", () => { const config: AppConfig = { environment: "development", server: { tls: { enabled: false } }, // ... other config }; const result = validateConfiguration(config, "development"); expect(result.valid).toBe(true); });}); // Test 3: Environment variable overrides work correctlydescribe("Environment Variable Overrides", () => { it("should override config file values with env vars", async () => { // Config file has port: 3000 process.env.APP_SERVER_PORT = "9000"; const config = await loadConfiguration(); expect(config.server.port).toBe(9000); }); it("should use config file value when env var not set", async () => { delete process.env.APP_SERVER_PORT; const config = await loadConfiguration(); expect(config.server.port).toBe(3000); // From config file });}); // Test 4: Secrets are not exposeddescribe("Secret Handling", () => { it("should not include secrets in serialized config", () => { const config = createTestConfig({ database: { credentialsRef: { source: "env", key: "DB_PASSWORD" } } }); const serialized = JSON.stringify(config); expect(serialized).not.toContain("password"); expect(serialized).not.toContain("secret"); }); it("should mask secrets in toString/logging output", () => { const config = createTestConfig(); const logged = config.toLoggableString(); expect(logged).toContain("[REDACTED]"); expect(logged).not.toContain("actual-password-value"); });});Environment-based configuration enables the same code to run differently across deployment contexts. Well-designed environment configuration maximizes parity while accommodating necessary differences, catches configuration errors before they cause production incidents, and supports secure secret management.
What's next:
Even perfect configuration is useless if it's incorrect. The next page explores configuration validation—how to catch invalid configuration at application startup before it causes runtime failures. We'll cover schema validation, dependency validation, and fail-fast startup patterns.
You now understand how to design configuration systems that adapt across deployment environments while maintaining parity and security. Next, we'll ensure that configuration is valid before your application starts accepting traffic.