schema validation, and explicit type coercion. The following implementation uses dotenv for file loading and zod for schema validation, providing a production-ready pattern.
Step 1: Centralized Environment Loader
Create a dedicated module responsible for loading environment files and parsing the configuration. This ensures all variables are available before any application logic executes.
// src/config/loader.ts
import * as dotenv from 'dotenv';
import { resolve } from 'path';
export function loadEnvironmentVariables(): void {
const envFilePath = process.env.ENV_FILE_PATH || resolve(process.cwd(), '.env');
dotenv.config({
path: envFilePath,
override: false,
encoding: 'utf8',
});
}
Rationale: Loading occurs in a single location with a configurable path via ENV_FILE_PATH. Setting override: false preserves values injected by the runtime environment, which is critical for containerized deployments where secrets are passed at runtime.
Step 2: Schema Definition with Zod
Define a strict schema that validates structure, types, and constraints. Use z.coerce for numeric values and z.preprocess for boolean conversions to handle string inputs robustly.
// src/config/schema.ts
import { z } from 'zod';
export const RuntimeConfigSchema = z.object({
SERVICE_PORT: z.coerce.number().int().positive().default(8080),
DB_CONNECTION_URI: z.string().url(),
AUTH_SIGNING_KEY: z.string().min(64),
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
ENABLE_METRICS: z.preprocess(
(val) => val === 'true' || val === '1',
z.boolean()
).default(false),
MAX_CONCURRENT_REQUESTS: z.coerce.number().int().min(1).max(1000).default(100),
});
export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
Rationale:
z.coerce.number() automatically converts string inputs to numbers.
.int().positive() and .min()/.max() enforce business constraints.
z.preprocess handles boolean conversion safely, accepting 'true' or '1'.
.default() values provide fallbacks for optional settings while maintaining type safety.
min(64) on the signing key enforces security best practices.
Step 3: Initialization and Export
Combine loading and parsing into an initialization function that exports the validated configuration object.
// src/config/index.ts
import { loadEnvironmentVariables } from './loader';
import { RuntimeConfigSchema, RuntimeConfig } from './schema';
let cachedConfig: RuntimeConfig | null = null;
export function initializeConfig(): RuntimeConfig {
if (cachedConfig) {
return cachedConfig;
}
loadEnvironmentVariables();
try {
cachedConfig = RuntimeConfigSchema.parse(process.env);
return cachedConfig;
} catch (error) {
if (error instanceof z.ZodError) {
const missingVars = error.errors
.map((e) => e.path.join('.'))
.join(', ');
throw new Error(
`Configuration validation failed. Missing or invalid variables: ${missingVars}`
);
}
throw error;
}
}
Rationale: Caching prevents repeated parsing. The try-catch block provides clear error messages listing exactly which variables failed validation, accelerating debugging. This pattern ensures the application cannot start with an invalid configuration state.
Step 4: Usage in Application Code
Import the initialized configuration in your entry point and pass it to modules that require it.
// src/index.ts
import { initializeConfig } from './config';
import { createServer } from './server';
const config = initializeConfig();
const server = createServer({
port: config.SERVICE_PORT,
logLevel: config.LOG_LEVEL,
metricsEnabled: config.ENABLE_METRICS,
});
server.listen(config.SERVICE_PORT, () => {
console.log(`Service running on port ${config.SERVICE_PORT}`);
});
Rationale: Dependency injection of the configuration object makes modules testable and decouples them from global state. The config object is fully typed, enabling IDE autocomplete and compile-time checks.
Pitfall Guide
1. The String Trap
Explanation: process.env always returns strings. Direct comparison of numeric values or boolean checks without conversion leads to logic errors.
Fix: Use z.coerce for numbers and z.preprocess for booleans in your schema. Never rely on implicit type coercion in business logic.
2. Late Environment Loading
Explanation: Loading dotenv after module imports can cause variables to be undefined during initialization of dependent modules.
Fix: Always load environment variables at the very top of your entry file, before any other imports that might access process.env.
3. Implicit Defaults in Logic
Explanation: Scattering default values across multiple modules creates inconsistency and makes it difficult to audit configuration.
Fix: Define all defaults in the schema. This centralizes configuration behavior and ensures a single source of truth.
4. Client-Side Secret Leakage
Explanation: Accidentally exposing environment variables to the browser via API responses or client-side bundles can leak sensitive data.
Fix: Implement an explicit allowlist for client-exposed configuration. Never serialize the entire config object to the frontend.
5. Committing Secrets to Version Control
Explanation: .env files containing secrets may be accidentally committed, especially in collaborative environments.
Fix: Maintain a .env.example file with dummy values for documentation. Use pre-commit hooks to scan for secrets. Ensure .env and variants are in .gitignore.
6. Runtime Mutation
Explanation: Modifying process.env during application execution can cause unpredictable behavior and race conditions.
Fix: Treat the configuration object as immutable. Do not modify environment variables after initialization.
7. Docker Image Bloat
Explanation: Copying .env files into Docker images bloats the image and risks embedding secrets in the container layer.
Fix: Exclude .env files from the Docker build context. Pass environment variables at runtime using docker run -e or orchestration secrets.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Solo Developer / MVP | .env + Zod Schema | Fast setup, low overhead, type safety | Free |
| Team / Enterprise | .env + Zod + Secret Manager | Auditability, rotation, access control | $$ |
| Kubernetes Deployment | ConfigMap/Secret + Zod | Native integration, secure injection | Free (infra cost) |
| Multi-Region App | .env + Zod + Remote Config | Dynamic updates, region-specific vars | $$ |
Configuration Template
.env.example
# Service Configuration
SERVICE_PORT=8080
LOG_LEVEL=info
ENABLE_METRICS=false
MAX_CONCURRENT_REQUESTS=100
# Database
DB_CONNECTION_URI=postgresql://user:password@localhost:5432/dbname
# Security
AUTH_SIGNING_KEY=replace-with-64-char-key
src/config/schema.ts
import { z } from 'zod';
export const RuntimeConfigSchema = z.object({
SERVICE_PORT: z.coerce.number().int().positive().default(8080),
DB_CONNECTION_URI: z.string().url(),
AUTH_SIGNING_KEY: z.string().min(64),
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
ENABLE_METRICS: z.preprocess(
(val) => val === 'true' || val === '1',
z.boolean()
).default(false),
MAX_CONCURRENT_REQUESTS: z.coerce.number().int().min(1).max(1000).default(100),
});
export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
Quick Start Guide
- Install Dependencies: Run
npm install zod dotenv to add schema validation and environment loading.
- Create Config Module: Set up
src/config/loader.ts, src/config/schema.ts, and src/config/index.ts using the templates above.
- Initialize in Entry Point: Import
initializeConfig in your main file and call it before starting the server.
- Create Example File: Add
.env.example with dummy values for team reference.
- Run Application: Start the app with
node src/index.ts. Missing or invalid variables will cause immediate failure with clear error messages.