Tags: web-dev concept

Environment Configuration

Date: 2026-08-17


Values that differ between local, preview and production. The rule that prevents most incidents is that configuration is injected at runtime, never committed — and the rule that prevents the rest is failing loudly at startup when something is missing.


Environment configuration is the set of values an application needs that vary by where it’s running: URLs, credentials, feature toggles, log levels.

The principle — from the twelve-factor app formulation — is strict separation of config from code. One artefact, configured differently per environment.

What is and isn’t config

CONFIG — varies by environment
  database URL
  API endpoints
  credentials — Secrets Management
  log level
  feature flag defaults

NOT CONFIG — the same everywhere
  routes
  business rules
  which framework
  ← these belong in code

See: Secrets Management

The test: would this differ between staging and production? If not, it’s code, and putting it in an environment variable adds indirection with no benefit.

Precedence

1  actual environment variables    ← highest
2  .env.local           (gitignored)
3  .env.[environment]   (committed, no
                         secrets)
4  .env                 (committed, no
                         secrets)
5  defaults in code                ← lowest

Real environment variables win, which is what lets CI and production inject values without any file.

COMMITTED             NEVER COMMITTED
.env.example          .env.local
.env.development      .env
  (non-secret only)   anything with a
                      credential

.env.example with empty values is the one to commit — it documents what’s needed without anyone having to guess.

Validate at startup

The practice that turns a class of runtime failure into a startup failure:

import { z } from 'zod'
 
const env = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  LOG_LEVEL: z.enum(['debug','info','warn','error'])
    .default('info'),
}).parse(process.env)
 
export default env

The same parse-at-the-edge pattern as any other untyped input — process.env is a type safety boundary like an API response (Type Safety Boundaries).

Without this, a missing variable surfaces as undefined deep in a request — a confusing error at 3am, or worse, a silent fallback to a wrong value.

With it, the application refuses to start and names the problem. The startsWith('sk_') check is worth copying: it catches the specific and very common failure of a test key deployed to production.

Client versus server

The distinction with real consequences:

SERVER-ONLY        DATABASE_URL
                   STRIPE_SECRET_KEY
                   → never reaches the
                     browser

CLIENT-EXPOSED     NEXT_PUBLIC_*
                   VITE_*
                   → bundled into the
                     JavaScript
                   → PUBLIC. Permanently

The prefix is a safety mechanism, and it means anything so prefixed is public — minification is not obfuscation, and a key in a bundle is a key on the internet — Secrets Management.

Client config is also baked in at build time, not read at runtime — so changing it needs a rebuild, and a single artefact can’t be promoted between environments if it contains environment-specific client values. That’s a real constraint on “build once, deploy everywhere” — Pipeline Design.

Where it goes wrong

  • A committed .env with real credentials. The most common and most damaging. Rotate first, then clean history — deleting it doesn’t un-leak it
  • Different values in each environment, undocumented. Production has a variable staging doesn’t, and nobody knows until deployment
  • Config drift. Someone changes a value in the production dashboard, and it exists nowhere in version control
  • Secrets in build logs. Echoing the environment for debugging, permanently, in a log aggregator with different access controls
  • Reading process.env throughout the codebase rather than through one validated module — untestable, and every access is a potential typo

Per-environment checklist

□  every variable in .env.example
□  validated at startup, fails loudly
□  secrets from a secret store, not
   a file
□  test-mode keys in preview and CI
   — Preview Environments
□  no client-exposed secrets
□  one module owns access to them
□  rotation is possible without a
   code change

See: Preview Environments

The last one is the test of whether it’s really configuration. If rotating a credential requires a deploy, it’s still coupled to the code.