cific files while maintaining a fallback chain.
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const runtimeEnv = process.env.RUNTIME_ENV || 'development';
// Priority: environment-specific > base .env > shell exports
dotenv.config({
path: path.resolve(__dirname, `../.env.${runtimeEnv}`),
override: false
});
dotenv.config({
path: path.resolve(__dirname, '../.env'),
override: false
});
Rationale: Setting override: false ensures that shell exports or CI/CD injected variables take precedence over file values. This aligns with the twelve-factor app methodology, where environment variables should override config files. Loading occurs synchronously at startup to guarantee availability before database clients, HTTP servers, or cache layers initialize.
Step 2: Schema Definition & Type Coercion
Raw environment variables are always strings. Direct usage leads to runtime type errors (e.g., passing "3000" to a port listener expecting a number). A schema object declares expected keys, required status, and coercion rules.
type ConfigSchema = {
RUNTIME_ENV: string;
SERVICE_PORT: number;
DATABASE_CONNECTION_STRING: string;
AUTH_TOKEN_TTL_SECONDS: number;
ENABLE_METRICS: boolean;
};
const schema: Record<keyof ConfigSchema, { required: boolean; coerce: (val: string) => any }> = {
RUNTIME_ENV: { required: true, coerce: (v) => v },
SERVICE_PORT: { required: true, coerce: (v) => {
const num = Number(v);
if (isNaN(num) || num < 1 || num > 65535) throw new Error('SERVICE_PORT must be a valid port number');
return num;
}},
DATABASE_CONNECTION_STRING: { required: true, coerce: (v) => v },
AUTH_TOKEN_TTL_SECONDS: { required: false, coerce: (v) => Number(v) || 3600 },
ENABLE_METRICS: { required: false, coerce: (v) => v.toLowerCase() === 'true' },
};
Rationale: Centralizing coercion logic prevents scattered parseInt() or Boolean() calls throughout the codebase. Validation runs once at startup, failing fast if constraints are violated. Optional fields receive sensible defaults, reducing boilerplate in downstream modules.
Step 3: Validation & Immutable Export
The loader iterates over the schema, checks for presence, applies coercion, and freezes the resulting object. Freezing prevents accidental mutation during request handling or background jobs.
function buildRuntimeConfig(): ConfigSchema {
const missing: string[] = [];
const config: Partial<ConfigSchema> = {};
for (const [key, rules] of Object.entries(schema)) {
const raw = process.env[key];
if (!raw && rules.required) {
missing.push(key);
continue;
}
try {
config[key as keyof ConfigSchema] = raw ? rules.coerce(raw) : undefined;
} catch (err) {
throw new Error(`Invalid value for ${key}: ${(err as Error).message}`);
}
}
if (missing.length > 0) {
throw new Error(`Missing required configuration: ${missing.join(', ')}`);
}
const frozenConfig = Object.freeze(config as ConfigSchema);
return frozenConfig;
}
export const runtimeConfig = buildRuntimeConfig();
Rationale: Object.freeze() guarantees that no middleware, logger, or utility can accidentally overwrite configuration values. This eliminates a class of bugs where runtime state drifts from the intended configuration. The validation step runs synchronously, ensuring the application never enters a partially initialized state.
Step 4: Secret Manager Fallback (Optional)
For production systems, file-based secrets should be deprecated in favor of a centralized vault. The loader can attempt a remote fetch before falling back to local files.
async function injectVaultSecrets(): Promise<void> {
const vaultAddr = process.env.VAULT_ADDRESS;
const vaultToken = process.env.VAULT_TOKEN;
if (!vaultAddr || !vaultToken) return;
try {
const response = await fetch(`${vaultAddr}/v1/secret/data/service-config`, {
headers: { 'X-Vault-Token': vaultToken }
});
if (!response.ok) return;
const payload = await response.json();
const secrets = payload.data?.data || {};
for (const [key, value] of Object.entries(secrets)) {
if (!process.env[key]) {
process.env[key] = String(value);
}
}
} catch {
// Silent fallback to .env files is acceptable in degraded vault scenarios
}
}
Rationale: Vault integration should never block startup. The async fetch runs before buildRuntimeConfig() is called, populating process.env with remote values. If the vault is unreachable, the application gracefully falls back to local files, maintaining availability while preserving security boundaries.
Pitfall Guide
1. Silent Undefined Fallbacks
Explanation: Developers often write process.env.MISSING_VAR || 'default' without realizing that undefined coerces to the default, masking missing configuration. This leads to applications running with incorrect database URLs or disabled security headers.
Fix: Enforce a strict schema. Require explicit declaration of all critical variables. Fail fast at startup if required keys are absent.
2. Process Environment Leakage in Logs
Explanation: Logging console.log(process.env) or passing the entire object to error reporters exposes API keys, database passwords, and internal tokens. Many monitoring tools automatically index log payloads, creating a persistent secret leak.
Fix: Never log the raw environment object. Use structured logging that explicitly whitelists safe keys (e.g., RUNTIME_ENV, SERVICE_PORT). Sanitize error payloads before transmission.
3. Shell Escaping & Multiline Breakage
Explanation: Values containing spaces, quotes, or newlines break when parsed by dotenv or shell exporters. Unquoted strings truncate at spaces, and multiline secrets (like RSA keys) fail to load entirely.
Fix: Wrap complex values in double quotes: PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...". For multiline data, prefer base64 encoding or external secret managers. Validate parsing during CI checks.
4. Docker Compose Precedence Conflicts
Explanation: Docker Compose merges environment variables from multiple sources: environment array, env_file, and host shell exports. The precedence order is often misunderstood, causing production values to be overwritten by local defaults.
Fix: Use environment for explicit overrides and env_file for baseline configuration. Never mix host shell exports with containerized deployments. Document the precedence chain in deployment runbooks.
5. Test Environment Contamination
Explanation: Test suites often inherit development or production .env files, causing tests to hit real databases or consume live API quotas. This leads to flaky tests, data corruption, and unexpected billing charges.
Fix: Isolate test configuration by setting RUNTIME_ENV=test explicitly in test runners. Use a dedicated .env.test file with mock endpoints and in-memory databases. Clear process.env between test suites if using global state.
6. Bypassing Validation for Convenience
Explanation: Teams sometimes disable validation in staging to speed up deployments, assuming "it works locally." This removes the safety net and allows malformed configuration to reach production.
Fix: Keep validation enabled across all environments. If a variable is optional in staging but required in production, handle it through schema rules, not by disabling the loader. Use feature flags instead of environment toggles for deployment gating.
7. Static Secret Rotation Neglect
Explanation: Secrets stored in .env files are rarely rotated because the process requires manual file updates and service restarts. Stale credentials increase the blast radius of a breach.
Fix: Integrate secret rotation into CI/CD pipelines. Use short-lived tokens where possible. For long-lived secrets, automate rotation via vault policies and trigger rolling deployments when values change.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small team, single environment | Validated dotenv with .env.example | Low overhead, fast onboarding, predictable | Near-zero |
| Multi-environment, frequent deployments | Schema validation + CI/CD secret injection | Eliminates config drift, enables automated rollouts | Low (CI runner time) |
| Enterprise, compliance requirements | Vault integration + runtime validation | Centralized rotation, audit trails, zero file secrets | Medium (vault licensing/ops) |
| Serverless / ephemeral workloads | Inline environment injection + strict schema | No persistent files, fast cold starts, immutable config | Low (provider limits) |
Configuration Template
// src/runtime/config.ts
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const runtimeEnv = process.env.RUNTIME_ENV || 'development';
dotenv.config({ path: path.resolve(__dirname, `../../.env.${runtimeEnv}`), override: false });
dotenv.config({ path: path.resolve(__dirname, '../../.env'), override: false });
type AppConfig = {
RUNTIME_ENV: string;
SERVICE_PORT: number;
DATABASE_URL: string;
JWT_SECRET: string;
ENABLE_DEBUG: boolean;
};
const schema: Record<keyof AppConfig, { required: boolean; coerce: (v: string) => any }> = {
RUNTIME_ENV: { required: true, coerce: (v) => v },
SERVICE_PORT: { required: true, coerce: (v) => {
const n = Number(v);
if (isNaN(n) || n < 1 || n > 65535) throw new Error('SERVICE_PORT must be 1-65535');
return n;
}},
DATABASE_URL: { required: true, coerce: (v) => v },
JWT_SECRET: { required: true, coerce: (v) => {
if (v.length < 32) throw new Error('JWT_SECRET must be >= 32 characters');
return v;
}},
ENABLE_DEBUG: { required: false, coerce: (v) => v.toLowerCase() === 'true' },
};
function buildConfig(): AppConfig {
const missing: string[] = [];
const cfg: Partial<AppConfig> = {};
for (const [key, rule] of Object.entries(schema)) {
const raw = process.env[key];
if (!raw && rule.required) { missing.push(key); continue; }
cfg[key as keyof AppConfig] = raw ? rule.coerce(raw) : undefined;
}
if (missing.length) throw new Error(`Missing config: ${missing.join(', ')}`);
return Object.freeze(cfg as AppConfig);
}
export const appConfig = buildConfig();
Quick Start Guide
- Initialize the loader: Place the configuration template at your application entry point. Ensure it executes before database clients, HTTP servers, or background workers.
- Create baseline files: Generate
.env.example with placeholder values and add .env, .env.*, and *.local to .gitignore. Commit the example file to version control.
- Validate locally: Run
RUNTIME_ENV=development node --import ./src/runtime/config.ts to verify schema validation and type coercion. Fix any missing or malformed keys before proceeding.
- Inject in CI/CD: Configure your pipeline to pass required variables through environment injection rather than file uploads. Set
RUNTIME_ENV=production and verify that shell values override file defaults.
- Freeze & deploy: Confirm
Object.freeze() is active in production builds. Run a startup health check that reads appConfig.SERVICE_PORT and appConfig.DATABASE_URL to guarantee configuration integrity before accepting traffic.