← back to the desk

Configuration management beyond secrets

8 September 2026 · 5 min read · #backend #config #security

written from this video


Ask a backend engineer what configuration management is and you'll get: database passwords, JWT secrets, API keys for whatever email service they're using. That's the answer, and it's about ten percent of the subject. It's like saying a car is the engine.

Config is every decision your code defers until startup. How the app boots. Which log level it writes at. How many database connections it holds open. Which features exist for which users. Whether a request times out at 30 seconds or 90. Those are behavioural decisions, and they live outside the code precisely so you can change them without a deploy.

The six kinds you'll actually meet

They look alike in a .env file and behave nothing alike once the app is running.

Application settings. Port, log level, connection pool size, timeouts. The most common and the least dramatic. Also the one that quietly ruins your afternoon: set the server timeout to 60s, then add an endpoint that generates an image and takes 80s on average, and every one of those requests dies with a 504 that has nothing to do with your image code.

Database config. Host, port, user, password, database name — usually assembled into one connection URL — plus query timeouts.

External services. Stripe, Resend, Clerk, whatever. Almost always an API key, and almost always the thing that leaks.

Feature flags. New checkout flow on for US users, old one for everyone else. AB tests. Kill switches. This is config that changes hourly rather than quarterly, which is a completely different storage problem.

Security settings. Session timeout, JWT secret, session secret.

Business rules. Maximum order value. Free shipping threshold. Things that are logic, but that product will want changed on a Friday without a release.

The reason to separate them is that they differ on three axes: how secret they are, how often they change, and whether they vary per environment. A log level is public, changes rarely, and differs everywhere. A Stripe key is fatal if leaked, changes on rotation, and differs between test and live. Storing both the same way means over-protecting one and under-protecting the other.

Why this gets hard: everything else is a service now

A backend that talks to one database can survive hardcoded values. A backend that talks to Postgres, Redis, a queue, an auth provider, an email provider, a payment processor and two internal services cannot, because each of those connections needs not just credentials but failure behaviour — retry counts, timeouts, circuit-breaker thresholds — and all of it varies per environment.

Without a strategy you get configuration chaos, and it has a recognisable shape: values hardcoded in three different files, behaviour that differs between staging and prod for reasons nobody can name, secrets in git history, and bugs you cannot reproduce because you don't actually know what config the broken instance was running.

A misconfigured frontend shows the wrong dialog. A misconfigured backend charges the wrong card.

Where it lives

Environment variables. The default. A .env file locally, loaded by dotenv or equivalent into the OS environment. In production the deploy pipeline pulls values from a secrets service and injects them before the process starts. Flat, stringly-typed, universally supported.

Files. YAML mostly, TOML increasingly, JSON rarely — JSON has no comments, which for a config file is a real problem. Files handle nesting properly, which env vars do not. Go through any large open source Go repo and you'll find a config.yaml with server, storage, logging and session sections in it.

Key-value stores. Consul, etcd. Lightweight, network-accessible, good when many services need the same value and you want to change it in one place.

Secrets managers. HashiCorp Vault, AWS Parameter Store, Azure Key Vault, Google Secret Manager. Encryption at rest and in transit, audit logs, rotation, access policies. You are not going to build these better yourself.

In practice it's layered. One loader, several sources, a fixed precedence:

defaults in code
  <- config.yaml
    <- environment variables
      <- secrets manager

Later overrides earlier. The app has one Config struct and doesn't know or care where any field came from.

Environments differ because priorities differ

Same code, different config, because each environment optimises for something else:

  • dev — developer speed and debuggability. Log level debug, verbose errors.
  • test — deterministic, automated validation. Fixtures, fast timeouts, no external calls.
  • staging — resemble production closely enough to catch real bugs.
  • production — reliability, security, performance.

Database pool size makes the tradeoff concrete. Dev: 10, plenty for one person on a laptop. Production: 50, because traffic spikes. Staging: 2 — even though staging is supposed to mirror production, mirroring it exactly costs production money, and staging is used by a handful of people who can absorb some queuing. Staging's real priority list has "don't burn cloud budget" near the top, and the config says so.

The security floor

Four rules, in ascending order of how often they're skipped.

Never hardcode secrets. Obvious. Still happens, usually as "temporary".

Use a managed secrets service. Encryption at rest, encryption in transit, rotation, audit trail — all of it out of the box. Over-engineering security is the one place where over-engineering is free.

Enforce least privilege. Frontend devs get the public API URL and their own integration keys. Backend devs get database, cache and search credentials. Only DevOps touches cloud infrastructure credentials. Rotate on a schedule so a leak has a shelf life.

Validate at startup. This is the one nobody does. The usual pattern is process.env.DATABASE_URL reached for at the point of use, which means a missing variable surfaces as undefined somewhere deep in a connection string an hour after deploy, as a weird error rather than a clear one.

Parse the whole config once, at boot, through a schema — Zod in TypeScript, go-playground/validator in Go, Pydantic in Python — and exit non-zero if anything is missing or malformed:

const Config = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().int().default(8080),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  DB_POOL_MAX: z.coerce.number().int().min(1).default(10),
});

export const config = Config.parse(process.env);

Five lines of schema turn a 3am production mystery into a container that refuses to start with a message naming the variable. Mandatory versus optional becomes explicit. Types become real — PORT is a number, not the string "8080". Defaults live in one place instead of scattered across || 10 expressions.

If you take one thing: validate your config at startup, whatever source it came from.