DevTools Hub

Search tools

Search for a developer tool

Configuration Best Practices

Part of the Environment Toolkit

Most configuration bugs aren't exotic — they're a missing variable nobody noticed until a request hit the one code path that reads it, or a default that quietly papered over a typo for months. None of the practices below are about a specific file format; they're about how an application loads and trusts its own configuration in the first place.

Fail fast at startup, not lazily at first use

A missing DATABASE_URL that only throws when the first request tries to query the database means the process looked healthy — passed its health check, accepted traffic — right up until it didn't. Validate every required piece of configuration once, at startup, before the process does anything else:

function loadConfig(env) {
  const required = ["DATABASE_URL", "PORT"];
  const missing = required.filter((key) => env[key] === undefined);
  if (missing.length > 0) {
    throw new Error(`Missing required environment variables: ${missing.join(", ")}`);
  }

  const port = Number(env.PORT);
  if (!Number.isInteger(port) || port <= 0) {
    throw new Error(`PORT must be a positive integer, got "${env.PORT}"`);
  }

  return { databaseUrl: env.DATABASE_URL, port, debug: env.DEBUG === "true" };
}

A process that refuses to start with a clear error is a five-second fix during deploy. A process that starts fine and fails mysteriously three hours into production traffic is a much longer night.

Centralize access behind one typed module

process.env.PORT scattered across a dozen files means a dozen places that each independently decide how to parse it, whether to default it, and what happens if it's missing — usually inconsistently. Read every environment variable in exactly one place, validate and convert it there, and have the rest of the codebase import the resulting typed object instead of touching process.env directly. Every value coming out of process.env is a string, always — PORT=3000 is the three-character string "3000", not the number 3000, until something explicitly converts it. Doing that conversion in one place means it only needs to be right once.

A convenient default can mask a real misconfiguration

const dbHost = process.env.DB_HOST || "localhost";

If the real environment variable is DB_HOSTT (typo'd) or was simply never set in this deployment, this line doesn't fail — it silently connects to localhost, which is exactly wrong in production and exactly the kind of bug that's painful to trace because nothing ever raised an error. A default is appropriate for a value that's genuinely optional (a log level, a feature flag). For anything where the "default" is really "this configuration is broken," skip the fallback and let the fail-fast check above catch it instead.

Make precedence explicit, and document it

Once a config value can come from more than one place — a checked-in defaults file, a local override file, an environment variable, a CLI flag — the order they override each other in has to be a deliberate, written-down decision, not whatever the loading code happens to do. A common, reasonable order: built-in defaults, then a config file, then environment variables, then CLI flags — each layer able to override everything before it. Whatever the order actually is, write it down somewhere a teammate debugging "why isn't my override taking effect" can find in ten seconds instead of reading loader code.

Keep secrets on a separate path from ordinary config

A database password and a log level are both "configuration," but they don't belong in the same file, the same access-control tier, or the same rotation policy. Mixing them means either the non-secret config gets locked down as tightly as the secrets (slowing everyone down for no reason), or the secrets get treated as casually as the non-secret config (a real risk). For where the line actually sits and when a dedicated secrets manager earns its complexity, see .env vs Secrets Managers.

Let environments differ on purpose, not by accident

Staging and production are supposed to differ in some values (URLs, resource limits) and supposed to match in others (which keys exist at all, which feature flags are set). The dangerous drift is the second kind — a variable present in one environment's config and quietly absent from another's, discovered only when that environment hits the code path that needed it. Environment Variable Diff compares two .env files by their actually-resolved values specifically to catch this before a deploy, not after.

Try it yourself

.env Validator checks a file against the dotenv format's own parsing gotchas — for the file-format mechanics specifically (quoting, the .env.example drift problem, converting between .env and YAML), see Managing Environment Variables. All the env tools run entirely in your browser.

Related tools