Guide

A running service reads its settings from several places at once. The defaults come from code, the environment supplies overrides, and secrets come from a vault. Config layering is the rule for which value wins. Validation is the check that stops a bad value from running at all.

The big idea

Think of layers like a set of transparent overlays. Each higher layer covers the one below it, but only where it has a value. The most specific layer that sets a key wins. The layers, from lowest to highest, are these.

defaults in code (safe, non-secret)
  -> config file for the environment class (timeouts, pool sizes)
    -> environment variables / platform config
      -> runtime flags / remote config
        -> secrets from a vault (never in git)

The analogy breaks when two layers disagree silently. A stale default can hide a missing environment variable, so the app runs with the wrong value and no error. Validation closes that gap.

Walking through a typed loader

Load config once, at startup, into a typed object. If a required value is missing or out of range, throw.

TypeScript
type Config = { port: number; databaseUrl: string; redisUrl: string; outboundTimeoutMs: number };

function loadConfig(env: NodeJS.ProcessEnv): Config {
  const port = Number(env.PORT ?? "8080");
  const databaseUrl = env.DATABASE_URL ?? "";
  const redisUrl = env.REDIS_URL ?? "";
  const outboundTimeoutMs = Number(env.OUTBOUND_TIMEOUT_MS ?? "3000");

  const errors: string[] = [];
  if (!Number.isInteger(port) || port < 1) errors.push("PORT");
  if (!databaseUrl.startsWith("postgres")) errors.push("DATABASE_URL");
  if (!redisUrl.startsWith("redis")) errors.push("REDIS_URL");
  if (!(outboundTimeoutMs >= 100)) errors.push("OUTBOUND_TIMEOUT_MS");
  if (errors.length) throw new Error(`Invalid config: ${errors.join(", ")}`);

  return { port, databaseUrl, redisUrl, outboundTimeoutMs };
}

try {
  loadConfig({ REDIS_URL: "redis://cache:6379" });
} catch (e) {
  console.log((e as Error).message);
}

Output:

Invalid config: DATABASE_URL

Failing at startup is kinder than serving 500s for twenty minutes. The pod restarts, and the deploy system sees failing health checks. It then marks the revision unhealthy and can roll back.

Tip

Log the non-secret config at boot, such as the region and pool sizes. Never log secret values. A short hash of the non-secret settings lets you confirm that two instances run the same release.

Secrets belong in a manager such as AWS Secrets Manager, GCP Secret Manager or Vault. They are injected as environment variables or files at runtime. The secrets and encryption page covers that in detail.

Common mistakes

  • Reading config lazily on the first request. The bad value then appears in production traffic, not in the deploy.
  • Silent defaults for required values. An empty DATABASE_URL should stop the boot, not fall back to something.
  • Logging secret values at boot. Log the names and a fingerprint, never the values.

Check yourself

  1. Why fail at startup rather than on the first request? Answer: A bad config then fails the deploy's health checks instead of returning errors to users for minutes.
  2. A default in code and an environment variable both set PORT. Which one wins? Answer: The environment variable, because it sits in a more specific layer.

Key takeaways

  • Config comes from layers, and the most specific layer that sets a key wins.
  • Load config once into a typed structure, and validate every required value.
  • Fail at startup, so the deploy system can see and stop a bad release.
  • Keep secrets in a vault, and never log their values.

Further learning

Next, see how a flag can turn a feature on without a deploy: feature flags.