Free Ebook cover Express.js Beyond Basics: Middleware, Architecture, and Maintainability

Express.js Beyond Basics: Middleware, Architecture, and Maintainability

New course

13 pages

Configuration Management: Environments, Secrets, and Safe Defaults

Capítulo 11

Estimated reading time: 10 minutes

+ Exercise

Why configuration management matters

Configuration is everything that changes between deployments without changing code: ports, database URLs, cookie settings, log levels, feature toggles, and third-party credentials. In production, configuration mistakes are a common cause of outages and security incidents. A robust configuration approach aims to: (1) validate at startup (fail fast), (2) keep configuration immutable (no runtime mutation), (3) separate secrets from code, (4) provide safe defaults where appropriate, and (5) make configuration injectable so it can be tested and overridden intentionally.

Design goals for a configuration module

  • Typed access: code should not read raw process.env all over the codebase.
  • Single source of truth: one module loads and validates environment variables.
  • Fail fast: invalid or missing required values should stop the process with clear messages.
  • Safe defaults: defaults only where they are genuinely safe (often for local dev), never for secrets.
  • Immutable: freeze the config object so accidental mutation is caught early.
  • Injectable: pass config into app creation and infrastructure constructors; avoid hidden global reads.

Step-by-step: build a typed, validated config module

1) Define a schema for environment variables

Use a runtime validator (e.g., zod) to parse process.env once, convert types, and produce a typed config object. This prevents subtle bugs like treating "0" as truthy or forgetting to set required values.

// src/config/env.ts
import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),

  // Server
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  HOST: z.string().default("0.0.0.0"),
  TRUST_PROXY: z.coerce.boolean().default(false),

  // Security
  COOKIE_SECRET: z.string().min(32).optional(),
  JWT_SECRET: z.string().min(32).optional(),
  CORS_ORIGINS: z.string().optional(),

  // Database
  DATABASE_URL: z.string().url().optional(),
  DB_POOL_MIN: z.coerce.number().int().min(0).default(0),
  DB_POOL_MAX: z.coerce.number().int().min(1).default(10),

  // Logging
  LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
  LOG_PRETTY: z.coerce.boolean().default(false),

  // Feature flags
  FF_NEW_CHECKOUT: z.coerce.boolean().default(false),
});

export type Env = z.infer<typeof EnvSchema>;

export function parseEnv(raw: NodeJS.ProcessEnv): Env {
  const result = EnvSchema.safeParse(raw);
  if (!result.success) {
    const issues = result.error.issues
      .map(i => `- ${i.path.join(".")}: ${i.message}`)
      .join("\n");
    throw new Error(`Invalid environment configuration:\n${issues}`);
  }
  return result.data;
}

2) Map env into configuration domains

Instead of a flat object, group configuration by domain. This makes it easier to reason about ownership and to inject only what a component needs.

// src/config/index.ts
import { parseEnv } from "./env";

export type ServerConfig = Readonly<{
  env: "development" | "test" | "production";
  host: string;
  port: number;
  trustProxy: boolean;
}>;

export type SecurityConfig = Readonly<{
  cookieSecret?: string;
  jwtSecret?: string;
  corsOrigins: string[];
}>;

export type DatabaseConfig = Readonly<{
  url?: string;
  pool: { min: number; max: number };
}>;

export type LoggingConfig = Readonly<{
  level: "fatal" | "error" | "warn" | "info" | "debug" | "trace";
  pretty: boolean;
}>;

export type FeatureFlags = Readonly<{
  newCheckout: boolean;
}>;

export type AppConfig = Readonly<{
  server: ServerConfig;
  security: SecurityConfig;
  database: DatabaseConfig;
  logging: LoggingConfig;
  features: FeatureFlags;
}>;

function splitCsv(value?: string): string[] {
  if (!value) return [];
  return value.split(",").map(s => s.trim()).filter(Boolean);
}

export function loadConfig(rawEnv = process.env): AppConfig {
  const env = parseEnv(rawEnv);

  const config: AppConfig = {
    server: {
      env: env.NODE_ENV,
      host: env.HOST,
      port: env.PORT,
      trustProxy: env.TRUST_PROXY,
    },
    security: {
      cookieSecret: env.COOKIE_SECRET,
      jwtSecret: env.JWT_SECRET,
      corsOrigins: splitCsv(env.CORS_ORIGINS),
    },
    database: {
      url: env.DATABASE_URL,
      pool: { min: env.DB_POOL_MIN, max: env.DB_POOL_MAX },
    },
    logging: {
      level: env.LOG_LEVEL,
      pretty: env.LOG_PRETTY,
    },
    features: {
      newCheckout: env.FF_NEW_CHECKOUT,
    },
  };

  return deepFreeze(config);
}

function deepFreeze<T>(obj: T): Readonly<T> {
  Object.freeze(obj);
  for (const value of Object.values(obj as any)) {
    if (value && typeof value === "object" && !Object.isFrozen(value)) {
      deepFreeze(value);
    }
  }
  return obj as Readonly<T>;
}

3) Add startup validation rules that depend on environment

Some values are only required in certain environments. For example, you might allow missing secrets in local development (if you use a local auth stub), but require them in production. Put these rules in a dedicated validation step so the error messages are explicit.

// src/config/validate.ts
import type { AppConfig } from "./index";

export function validateConfigOrThrow(config: AppConfig): void {
  const errors: string[] = [];

  if (config.server.env === "production") {
    if (!config.security.cookieSecret) errors.push("SECURITY: COOKIE_SECRET is required in production");
    if (!config.security.jwtSecret) errors.push("SECURITY: JWT_SECRET is required in production");
    if (!config.database.url) errors.push("DATABASE: DATABASE_URL is required in production");
    if (config.logging.pretty) errors.push("LOGGING: LOG_PRETTY should be false in production");
  }

  if (config.server.trustProxy && config.server.env !== "production") {
    // Example policy: only enable trust proxy in production behind a known proxy.
    // Adjust to your deployment reality.
  }

  if (errors.length) {
    throw new Error(`Configuration validation failed:\n${errors.map(e => `- ${e}`).join("\n")}`);
  }
}

Call this once during process startup, before the server begins listening.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

// src/main.ts
import { loadConfig } from "./config";
import { validateConfigOrThrow } from "./config/validate";
import { createApp } from "./app";

const config = loadConfig();
validateConfigOrThrow(config);

const app = createApp({ config });

app.listen(config.server.port, config.server.host, () => {
  // log startup message via your logger
});

Configuration domains in practice

Server domain: ports, host, proxy, timeouts

Server configuration should be small and predictable. Prefer safe defaults for local development (e.g., port 3000) but validate constraints (valid port range). If you deploy behind a reverse proxy, treat trustProxy as a deliberate setting because it affects how client IP and protocol are interpreted.

SettingExample env varNotes
PortPORTSafe default OK; validate integer range.
HostHOSTDefault 0.0.0.0 is common in containers.
Trust proxyTRUST_PROXYShould match your infra; wrong value can break IP-based logic.

Security domain: secrets, cookies, CORS allowlists

Security configuration is where “safe defaults” are most dangerous. A missing secret should usually be a startup error in production. Avoid generating secrets at runtime in production because it breaks session continuity and can cause hard-to-debug auth issues after restarts.

  • Secrets: COOKIE_SECRET, JWT_SECRET, API keys. Never default these.
  • Allowlists: parse CORS_ORIGINS as CSV into an array; validate format if needed.
  • Environment-dependent behavior: allow relaxed settings only in development/test, but make production strict.

Database domain: connection URL and pool sizing

Database configuration should be explicit and validated. A common pattern is to require DATABASE_URL in production, but allow it to be optional in tests (where you might use an in-memory alternative) or in local development (where a developer might run a local DB with a known URL).

// Example: enforce pool bounds
// already handled by zod min/max; add policy checks if needed
if (config.database.pool.max < config.database.pool.min) {
  throw new Error("DATABASE: DB_POOL_MAX must be >= DB_POOL_MIN");
}

Logging domain: level and formatting

Logging config should be simple: level and formatting. A safe default is typically info. Pretty printing is usually a development-only convenience; validate that it is disabled in production if that’s your policy.

Keeping config immutable and injectable

Immutability: prevent accidental runtime changes

Freezing the config object makes configuration a read-only contract. This helps catch bugs where code tries to “temporarily” change a setting (which can lead to inconsistent behavior across requests).

// already shown: deepFreeze(config)
// usage: config.server.port is read-only by type and by runtime freeze

Injectability: avoid hidden global dependencies

Instead of importing config everywhere (which makes tests harder and encourages implicit coupling), inject config into constructors and factories. This keeps dependencies explicit and allows tests to supply minimal config.

// src/app.ts
import express from "express";
import type { AppConfig } from "./config";

export function createApp({ config }: { config: AppConfig }) {
  const app = express();

  // Example: use config to set express trust proxy
  app.set("trust proxy", config.server.trustProxy);

  // Example: inject config into route factories or services as needed
  // app.use(createRouter({ config }));

  return app;
}

For unit tests, you can load a baseline config and override only the relevant domain values.

// test/helpers/config.ts
import { loadConfig } from "../../src/config";

export function makeTestConfig(overrides: Partial<ReturnType<typeof loadConfig>> = {}) {
  const base = loadConfig({
    NODE_ENV: "test",
    PORT: "0",
  } as any);

  // shallow merge is fine if you override whole domains; otherwise implement a deep merge
  return Object.freeze({ ...base, ...overrides });
}

Secret handling patterns

Do not commit secrets

Do not store secrets in source control. This includes API keys, private keys, database passwords, and cookie/JWT secrets. Treat any committed secret as compromised and rotate it.

Use environment injection (runtime-provided secrets)

Provide secrets to the process via environment variables injected by your runtime: container orchestrator, CI/CD, or a secret manager that maps secrets into environment variables. Your application should only read them at startup through the config module.

  • Local development: use a local, uncommitted .env file (ignored by git) or shell exports.
  • CI: inject secrets via CI secret storage into environment variables.
  • Production: inject secrets via your platform’s secret manager (mapped to env vars or mounted files).

Prefer separate variables for separate concerns

Avoid reusing one secret for multiple purposes (e.g., using the same value for cookies and JWT signing). Separate secrets reduce blast radius and make rotation safer.

Rotation-friendly design

When possible, support secret rotation without downtime. A common approach is to allow multiple valid signing keys (current + previous) for verification while only using the current key for signing. Model this explicitly in config if you need it.

// Example env: JWT_SECRETS="current,previous"
// config.security.jwtSecrets: string[]
// sign with jwtSecrets[0], verify with all

Feature flags: controlled behavior changes

Feature flags let you enable/disable behavior without redeploying code. Keep flags in the config module so they are typed and validated like everything else.

Simple boolean flags

// env.ts
FF_NEW_CHECKOUT: z.coerce.boolean().default(false)

// usage
if (config.features.newCheckout) {
  // new path
} else {
  // old path
}

Multi-variant flags

Sometimes you need more than on/off (e.g., "control", "variantA", "variantB"). Validate variants explicitly.

// env.ts
FF_SEARCH_VARIANT: z.enum(["control", "v1", "v2"]).default("control")

Flag safety rules

  • Default to off for risky features.
  • Validate combinations if some flags are mutually exclusive.
  • Keep flags discoverable: centralize them under config.features so you can audit what exists.

Fail fast with clear messages: preventing misconfiguration

Make errors actionable

When validation fails, the error should tell the operator exactly what to fix. Prefer messages that reference the environment variable name and the expected format.

Invalid environment configuration:
- PORT: Expected number, received nan
- DATABASE_URL: Invalid url

Configuration validation failed:
- SECURITY: JWT_SECRET is required in production

Validate at process start, before listening

Do not defer config checks until the first request. Load and validate config before creating network listeners or background jobs. This ensures misconfigured deployments fail immediately and consistently.

Be strict in production, flexible in tests

Tests often need minimal configuration. Keep the schema strict about types, but allow optional values in NODE_ENV=test and enforce production requirements in validateConfigOrThrow. This avoids littering the schema with environment-specific exceptions while still guaranteeing production safety.

Document expected env vars as code

The schema is the documentation. To make it more operator-friendly, you can generate a list of required variables per environment (or print a short summary at startup without revealing secret values).

// Example: safe startup summary (do not print secrets)
// SERVER: env=production host=0.0.0.0 port=8080 trustProxy=true
// DATABASE: configured=true
// SECURITY: cookieSecret=present jwtSecret=present
// FEATURES: newCheckout=false

Now answer the exercise about the content:

Which approach best aligns with a robust configuration strategy for an Express.js application?

You are right! Congratulations, now go to the next page

You missed! Try again.

A strong setup validates at startup (fail fast), separates secrets from code, avoids safe defaults for secrets, freezes config to prevent mutation, and injects config to keep dependencies explicit and testable.

Next chapter

Testing Express.js Applications: Unit, Integration, and Middleware Tests

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.