Configuration and Secrets Management

Keeping what changes per environment out of the code that doesn't, validating configuration once at startup with a typed schema instead of scattered os.environ.get calls, and where the credentials advice repeated across this site actually comes from.

Several articles on this site — Connecting to PostgreSQL from Python, API Authentication Patterns — include some version of the same callout: credentials belong in configuration, not source code. That advice has been repeated rather than explained in depth because it did not yet have a home of its own. This is that home.

Config lives in the environment, not the code#

The twelve-factor app's config principle states this plainly: config is what varies between deploys — a database host, an API base URL, a feature flag — and code is what does not. A project structured this way could be made public at any moment without exposing a single credential, because none of them were ever written into the code to begin with:

Python
import os
DB_HOST = os.environ["DB_HOST"]DB_PASSWORD = os.environ["DB_PASSWORD"]

This already satisfies the "not hardcoded" bar every earlier callout on this site was making. It has a different problem: os.environ["DB_PASSWORD"] raises KeyError: 'DB_PASSWORD' wherever it happens to run first — possibly deep inside a function called ten minutes into a scheduled job — rather than immediately, clearly, at startup.

Validate configuration once, at startup#

A typed config object, built once when the process starts, turns "missing configuration" into a single clear failure before any real work begins:

Python
from dataclasses import dataclass
@dataclass(frozen=True)class Config:    db_host: str    db_password: str    poll_interval: int = 10
def load_config() -> Config:    return Config(        db_host=os.environ["DB_HOST"],        db_password=os.environ["DB_PASSWORD"],        poll_interval=int(os.environ.get("POLL_INTERVAL", 10)),    )

Every required environment variable is read in one place, at one time. A deployment missing DB_HOST fails immediately with a clear KeyError naming exactly what is missing, instead of failing confusingly whenever the code path that needs it first runs.

For a larger config surface, Pydantic's settings model does the same thing with type coercion and validation rules built in, rather than hand-written conversions like int(...) above — worth the extra dependency once a config object grows past a handful of fields or needs real validation beyond "does this string exist."

Config and secrets are not quite the same thing#

A config value — a poll interval, a feature flag, a base URL — is safe to log, safe to commit to a .env.example template, and only inconvenient if wrong. A secret — a password, an API key, a signing key — must never be logged, never committed, and often benefits from a dedicated secrets manager (cloud-provider secret stores, HashiCorp Vault) rather than a plain environment variable once a team grows past a handful of people who all need access.

A .env file is still a file

.env files are a common convenience for local development, loaded into os.environ at startup by a small library such as python-dotenv. They are not a secrets manager — a .env file committed to version control by accident is exactly the "credentials in source" problem this whole article exists to avoid, just one file removed from the literal code. Add .env to .gitignore before the first secret ever goes into one.

Common mistakes#

Reading os.environ scattered throughout the codebase#

Every direct os.environ[...] call outside one central config-loading function is a place a missing variable can fail unpredictably, and a place someone editing the code later has to remember to check. Load and validate configuration once; pass the resulting object around like any other dependency.

Giving a required secret a silent default#

os.environ.get("DB_PASSWORD", "") turns a missing, genuinely required secret into an empty string that fails much later, much less clearly — a failed database connection instead of an immediate, obvious configuration error. Use os.environ[...] (which raises) for anything that must be present; reserve .get(..., default) for values that are truly optional.

Committing a .env file "just for now"#

The file that was committed temporarily to unblock a deploy is the file that stays in git history permanently, readable by anyone with repository access, indefinitely — rotating the leaked credential afterward does not remove it from history.

Further reading#

See also#