February 5, 2026
Claude Development

Centralizing Config and Environment Variable Usage

You've probably been there: scattered process.env.SOMETHING calls sprinkled throughout your codebase like confetti. One file checks process.env.API_KEY, another assumes process.env.DATABASE_URL exists, and nobody really knows what happens if either one is missing. By the time you're running in production, you've got a patchwork of environment assumptions that makes deployment fragile and debugging painful.

This is exactly the kind of technical debt that compounds quietly. Each new file adds another direct environment variable read, the pattern spreads, and suddenly your codebase is tightly coupled to your environment configuration. You can't test easily. You can't validate configuration upfront. You definitely can't be confident about what's actually required to run your application.

We're going to fix that. This guide walks through building a centralized config system for Claude Code projects—one that gives you type safety, validation, defaults, and a single source of truth for all your environment-based configuration. By the end, you'll have a production-ready pattern that makes environment handling transparent, testable, and maintainable.

Table of Contents
  1. Why Centralization Matters: The Real Costs
  2. Building Your Centralized Config System
  3. Step 1: Define Your Configuration Schema
  4. Step 2: Create a Configuration Loader
  5. Step 3: Validate Configuration at Startup
  6. Step 4: Create a Global Config Instance
  7. Step 5: Use Configuration Throughout Your Application
  8. Testing with Injected Configuration
  9. Documentation and Discovery
  10. Application Settings
  11. Database Configuration
  12. Authentication Configuration
  13. Environment-Specific Configuration
  14. Advanced: Configuration Inheritance and Layering
  15. Monitoring Configuration Drift
  16. Safety: Preventing Configuration Mistakes
  17. Integration with CI/CD
  18. Conclusion: The Power of Centralization

Why Centralization Matters: The Real Costs

Before we dive into implementation, let's talk about why this actually matters beyond "cleaner code."

The Hidden Costs of Scattered Env Reads

When you scatter process.env.X calls throughout your code, you're creating a distributed contract between your application and its runtime environment. Nobody knows what the actual contract is. Is DATABASE_HOST actually required? Does it have a default? What happens if it's missing? These questions are answered by wherever that variable was last used, often with different answers in different places.

This fragmentation creates operational chaos. When something breaks in production, you have to hunt through code to understand which environment variables are involved. You have to rebuild what the actual configuration contract is by reading code, not by consulting documentation. This costs hours during incident response.

It also creates testing problems. You can't test your application easily because you don't know which environment variables are actually required versus optional. You have to set up 20 different variables in your test environment, most of which don't matter. Your tests are brittle because they depend on environment state. You can't run tests reliably on different machines because environment setup varies.

More subtly, it causes deployment problems. When you add a new environment variable to some random file in the codebase, nobody realizes it's now required in production. Your CI/CD pipeline doesn't fail (the code is fine), so you deploy happily. Then production crashes because the variable is missing. You scramble to add it and redeploy. This happens repeatedly. It's preventable.

The Architectural Problem

The deeper issue is architectural. When code directly reads environment variables, it's saying "I own my own configuration." But that's not how distributed systems work. Configuration is actually a cross-cutting concern—it's needed everywhere, it has dependencies, it needs to be validated as a whole. When every module reads its own configuration independently, you lose sight of the big picture.

A centralized config system inverts this. Instead of every module reading configuration independently, a single system owns configuration—reads it, validates it, provides defaults, documents it. Every other module just asks for what it needs. This architecture enables you to:

  • Validate configuration upfront (catch misconfiguration before runtime)
  • See the entire configuration contract at a glance (documentation)
  • Test easily (inject test configuration)
  • Deploy reliably (CI/CD can verify all required variables are set)
  • Refactor configuration safely (change how things are configured without breaking code)

Building Your Centralized Config System

Let's build a real centralized config system for a Node.js/TypeScript application. This is pattern you can adapt to other languages and frameworks.

Step 1: Define Your Configuration Schema

Start by listing all configuration your application needs:

typescript
// src/config/schema.ts
// Defines all configuration your application needs
 
interface AppConfig {
  // Core application settings
  app: {
    name: string;
    version: string;
    environment: "development" | "staging" | "production";
    port: number;
    debugMode: boolean;
  };
 
  // Database configuration
  database: {
    host: string;
    port: number;
    username: string;
    password: string;
    name: string;
    poolSize: number;
    ssl: boolean;
  };
 
  // Authentication settings
  auth: {
    jwtSecret: string;
    jwtExpiryHours: number;
    refreshTokenExpiryDays: number;
    passwordMinLength: number;
  };
 
  // External API integrations
  apis: {
    stripe: {
      apiKey: string;
      webhookSecret: string;
    };
    sendgrid: {
      apiKey: string;
      fromEmail: string;
    };
    slack: {
      webhookUrl: string;
      enabled: boolean;
    };
  };
 
  // Feature flags
  features: {
    newPaymentFlow: boolean;
    betaAnalytics: boolean;
    maintenanceMode: boolean;
  };
 
  // Logging and monitoring
  logging: {
    level: "debug" | "info" | "warn" | "error";
    format: "json" | "text";
    destination: "console" | "file" | "both";
  };
}

Step 2: Create a Configuration Loader

Build a loader that reads from environment variables and applies defaults:

typescript
// src/config/loader.ts
// Reads environment variables and loads configuration
 
import dotenv from "dotenv";
 
// Load .env file if it exists (development only)
dotenv.config();
 
const getEnv = (key: string, defaultValue?: string): string => {
  const value = process.env[key];
 
  if (!value && defaultValue === undefined) {
    throw new Error(`Required environment variable missing: ${key}`);
  }
 
  return value || defaultValue!;
};
 
const getEnvInt = (key: string, defaultValue?: number): number => {
  const value = process.env[key];
 
  if (!value && defaultValue === undefined) {
    throw new Error(`Required environment variable missing: ${key}`);
  }
 
  return value ? parseInt(value, 10) : defaultValue!;
};
 
const getEnvBool = (key: string, defaultValue?: boolean): boolean => {
  const value = process.env[key];
 
  if (!value && defaultValue === undefined) {
    throw new Error(`Required environment variable missing: ${key}`);
  }
 
  if (!value) return defaultValue!;
 
  return ["true", "1", "yes", "on"].includes(value.toLowerCase());
};
 
export function loadConfig(): AppConfig {
  return {
    app: {
      name: getEnv("APP_NAME", "MyApp"),
      version: getEnv("APP_VERSION", "1.0.0"),
      environment: getEnv("NODE_ENV", "development") as any,
      port: getEnvInt("PORT", 3000),
      debugMode: getEnvBool("DEBUG", false),
    },
 
    database: {
      host: getEnv("DB_HOST", "localhost"),
      port: getEnvInt("DB_PORT", 5432),
      username: getEnv("DB_USER"),
      password: getEnv("DB_PASSWORD"),
      name: getEnv("DB_NAME"),
      poolSize: getEnvInt("DB_POOL_SIZE", 10),
      ssl: getEnvBool("DB_SSL", false),
    },
 
    auth: {
      jwtSecret: getEnv("JWT_SECRET"),
      jwtExpiryHours: getEnvInt("JWT_EXPIRY_HOURS", 24),
      refreshTokenExpiryDays: getEnvInt("REFRESH_TOKEN_EXPIRY_DAYS", 30),
      passwordMinLength: getEnvInt("PASSWORD_MIN_LENGTH", 8),
    },
 
    apis: {
      stripe: {
        apiKey: getEnv("STRIPE_API_KEY"),
        webhookSecret: getEnv("STRIPE_WEBHOOK_SECRET"),
      },
      sendgrid: {
        apiKey: getEnv("SENDGRID_API_KEY"),
        fromEmail: getEnv("SENDGRID_FROM_EMAIL", "noreply@example.com"),
      },
      slack: {
        webhookUrl: getEnv("SLACK_WEBHOOK_URL", ""),
        enabled: getEnvBool("SLACK_ENABLED", false),
      },
    },
 
    features: {
      newPaymentFlow: getEnvBool("FEATURE_NEW_PAYMENT_FLOW", false),
      betaAnalytics: getEnvBool("FEATURE_BETA_ANALYTICS", false),
      maintenanceMode: getEnvBool("MAINTENANCE_MODE", false),
    },
 
    logging: {
      level: getEnv("LOG_LEVEL", "info") as any,
      format: getEnv("LOG_FORMAT", "text") as any,
      destination: getEnv("LOG_DESTINATION", "console") as any,
    },
  };
}

Step 3: Validate Configuration at Startup

Add validation to ensure all required configuration is present and valid:

typescript
// src/config/validator.ts
// Validates configuration at startup
 
import { AppConfig } from "./schema";
 
interface ValidationError {
  path: string;
  message: string;
}
 
export function validateConfig(config: AppConfig): ValidationError[] {
  const errors: ValidationError[] = [];
 
  // Validate app configuration
  if (config.app.port < 1 || config.app.port > 65535) {
    errors.push({
      path: "app.port",
      message: "Port must be between 1 and 65535",
    });
  }
 
  // Validate database configuration
  if (!config.database.host) {
    errors.push({
      path: "database.host",
      message: "Database host is required",
    });
  }
 
  if (config.database.poolSize < 1 || config.database.poolSize > 100) {
    errors.push({
      path: "database.poolSize",
      message: "Pool size must be between 1 and 100",
    });
  }
 
  // Validate auth configuration
  if (config.auth.jwtSecret.length < 32) {
    errors.push({
      path: "auth.jwtSecret",
      message: "JWT secret must be at least 32 characters",
    });
  }
 
  if (config.auth.passwordMinLength < 6) {
    errors.push({
      path: "auth.passwordMinLength",
      message: "Password minimum length must be at least 6",
    });
  }
 
  // Validate API keys are present in production
  if (config.app.environment === "production") {
    if (!config.apis.stripe.apiKey) {
      errors.push({
        path: "apis.stripe.apiKey",
        message: "Stripe API key required in production",
      });
    }
 
    if (!config.apis.sendgrid.apiKey) {
      errors.push({
        path: "apis.sendgrid.apiKey",
        message: "SendGrid API key required in production",
      });
    }
  }
 
  return errors;
}
 
export function validateConfigOrThrow(config: AppConfig): void {
  const errors = validateConfig(config);
 
  if (errors.length > 0) {
    const errorMessage = errors
      .map((e) => `${e.path}: ${e.message}`)
      .join("\n");
 
    throw new Error(`Configuration validation failed:\n${errorMessage}`);
  }
}

Step 4: Create a Global Config Instance

Build a singleton that provides configuration throughout your application:

typescript
// src/config/index.ts
// Exports the global configuration instance
 
import { loadConfig } from "./loader";
import { validateConfigOrThrow } from "./validator";
import type { AppConfig } from "./schema";
 
let config: AppConfig | null = null;
 
export function initializeConfig(): AppConfig {
  if (config) {
    return config; // Already initialized
  }
 
  config = loadConfig();
  validateConfigOrThrow(config);
 
  console.log(
    `Configuration loaded for environment: ${config.app.environment}`,
  );
 
  return config;
}
 
export function getConfig(): AppConfig {
  if (!config) {
    throw new Error(
      "Configuration not initialized. Call initializeConfig() first.",
    );
  }
 
  return config;
}
 
export type { AppConfig };

Step 5: Use Configuration Throughout Your Application

Now that you have a centralized config system, use it everywhere:

typescript
// src/main.ts
// Application entry point
 
import { initializeConfig, getConfig } from "./config";
import express from "express";
import database from "./db";
 
async function main() {
  // Initialize configuration first
  const config = initializeConfig();
 
  // Create application
  const app = express();
 
  // Configure database using centralized config
  await database.initialize({
    host: config.database.host,
    port: config.database.port,
    username: config.database.username,
    password: config.database.password,
    database: config.database.name,
    poolSize: config.database.poolSize,
  });
 
  // Start server
  app.listen(config.app.port, () => {
    console.log(`Server running on port ${config.app.port}`);
  });
}
 
main().catch((error) => {
  console.error("Failed to start application:", error);
  process.exit(1);
});

In route handlers:

typescript
// src/routes/auth.ts
import { getConfig } from "../config";
import jwt from "jsonwebtoken";
 
export function createToken(userId: string) {
  const config = getConfig();
 
  const token = jwt.sign({ userId }, config.auth.jwtSecret, {
    expiresIn: `${config.auth.jwtExpiryHours}h`,
  });
 
  return token;
}

Testing with Injected Configuration

With centralized configuration, testing becomes simpler. You can inject test configuration:

typescript
// src/__tests__/auth.test.ts
import { initializeConfig, getConfig } from "../config";
 
describe("Authentication", () => {
  beforeEach(() => {
    // Set test environment variables
    process.env.JWT_SECRET = "test-secret-that-is-at-least-32-chars-long";
    process.env.JWT_EXPIRY_HOURS = "1";
  });
 
  test("creates token with correct expiry", () => {
    // Configuration is loaded with test values
    const config = getConfig();
 
    expect(config.auth.jwtExpiryHours).toBe(1);
  });
});

Documentation and Discovery

With centralized configuration, you can auto-generate documentation of what's required:

typescript
// scripts/generate-config-docs.ts
// Generates documentation of all configuration variables
 
import { loadConfig } from "../config/loader";
import type { AppConfig } from "../config/schema";
 
function generateMarkdown(config: AppConfig): string {
  return `
# Configuration Reference
 
## Application Settings
 
- \`APP_NAME\`: Application name (default: "MyApp")
- \`APP_VERSION\`: Application version (default: "1.0.0")
- \`NODE_ENV\`: Environment: development, staging, or production (required)
- \`PORT\`: Server port (default: 3000, range: 1-65535)
- \`DEBUG\`: Enable debug mode (default: false)
 
## Database Configuration
 
- \`DB_HOST\`: Database hostname (default: "localhost")
- \`DB_PORT\`: Database port (default: 5432)
- \`DB_USER\`: Database username (required)
- \`DB_PASSWORD\`: Database password (required)
- \`DB_NAME\`: Database name (required)
- \`DB_POOL_SIZE\`: Connection pool size (default: 10, range: 1-100)
- \`DB_SSL\`: Enable SSL for database connections (default: false)
 
## Authentication Configuration
 
- \`JWT_SECRET\`: JWT signing secret (required, min: 32 chars)
- \`JWT_EXPIRY_HOURS\`: JWT expiration time in hours (default: 24)
- \`REFRESH_TOKEN_EXPIRY_DAYS\`: Refresh token expiration in days (default: 30)
- \`PASSWORD_MIN_LENGTH\`: Minimum password length (default: 8, min: 6)
 
...more sections...
  `.trim();
}

Environment-Specific Configuration

For different environments, use environment-specific files:

bash
# .env.development (checked into git, no secrets)
APP_NAME=MyApp
APP_VERSION=1.0.0
NODE_ENV=development
PORT=3000
DEBUG=true
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_dev
FEATURE_NEW_PAYMENT_FLOW=false
 
# .env.production (in secrets management, NOT in git)
# Never commit secrets to git
APP_NAME=MyApp
APP_VERSION=1.0.0
NODE_ENV=production
PORT=3000
DEBUG=false
DB_HOST=db.prod.example.com
DB_PASSWORD=***SECRET***

Advanced: Configuration Inheritance and Layering

For complex applications, layer configuration:

typescript
// src/config/layers.ts
 
import { loadConfig as loadFromEnv } from "./loader";
import type { AppConfig } from "./schema";
 
interface ConfigLayer {
  name: string;
  load(): Partial<AppConfig>;
}
 
const layers: ConfigLayer[] = [
  {
    name: "defaults",
    load: () => ({
      app: { debugMode: false, port: 3000 },
      logging: { level: "info" },
    }),
  },
  {
    name: "environment-specific",
    load: () => {
      if (process.env.NODE_ENV === "production") {
        return { logging: { level: "warn" } };
      }
      return { logging: { level: "debug" } };
    },
  },
  {
    name: "environment-variables",
    load: loadFromEnv,
  },
];
 
export function loadLayeredConfig(): AppConfig {
  let config: Partial<AppConfig> = {};
 
  for (const layer of layers) {
    const layerConfig = layer.load();
    config = deepMerge(config, layerConfig);
  }
 
  return config as AppConfig;
}

Monitoring Configuration Drift

Track when configuration changes:

typescript
// src/config/monitor.ts
 
import { getConfig } from "./index";
 
export function logCurrentConfiguration(): void {
  const config = getConfig();
 
  const summary = {
    environment: config.app.environment,
    port: config.app.port,
    debugMode: config.app.debug Mode,
    databaseHost: config.database.host,
    features: config.features,
    logging: config.logging,
  };
 
  console.log("Current Configuration:");
  console.log(JSON.stringify(summary, null, 2));
}

Safety: Preventing Configuration Mistakes

Add guards to prevent common configuration mistakes:

typescript
// src/config/guards.ts
 
import { getConfig } from "./index";
 
export function checkProductionSafety(): void {
  const config = getConfig();
 
  if (config.app.environment !== "production") {
    return; // Only check in production
  }
 
  const issues: string[] = [];
 
  if (config.app.debugMode) {
    issues.push("DEBUG mode is enabled in production!");
  }
 
  if (config.database.poolSize < 5) {
    issues.push("Database pool size is very low for production");
  }
 
  if (!config.apis.stripe.apiKey) {
    issues.push("Stripe API key is not configured");
  }
 
  if (issues.length > 0) {
    throw new Error(
      `Production configuration issues found:\n${issues.map((i) => `- ${i}`).join("\n")}`,
    );
  }
}

Integration with CI/CD

In your CI/CD pipeline, validate configuration before deploying:

bash
#!/bin/bash
# scripts/validate-config.sh
# Validates configuration before deployment
 
set -e
 
echo "Validating configuration..."
 
# Check for required production variables
if [ "$ENVIRONMENT" = "production" ]; then
  required_vars=(
    "JWT_SECRET"
    "DB_PASSWORD"
    "STRIPE_API_KEY"
    "SENDGRID_API_KEY"
  )
 
  for var in "${required_vars[@]}"; do
    if [ -z "${!var}" ]; then
      echo "❌ Required variable missing: $var"
      exit 1
    fi
  done
 
  echo "✅ All required production variables are set"
fi
 
# Run application's configuration validation
npm run validate-config
 
echo "✅ Configuration validation passed"

Conclusion: The Power of Centralization

A centralized configuration system gives you:

  • Type Safety: TypeScript knows your configuration shape
  • Validation: Catch misconfiguration before runtime
  • Defaults: Sensible defaults, overridable by environment
  • Documentation: Configuration requirements are self-documenting
  • Testing: Easy to inject test configuration
  • Auditability: All configuration in one place
  • Reliability: CI/CD can verify configuration before deployment

The investment in building a centralized config system pays back immediately through fewer deployment issues, easier testing, and faster development. Every new environment variable just gets added to the schema and loader—no hunting through code.


-iNet

Need help implementing this?

We build automation systems like this for clients every day.

Discuss Your Project