Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration Management

Interview answer (say this first). Configuration is everything that changes between environments — database URLs, credentials, feature flags — while the code stays identical. Keep it out of code, load it from the environment, validate it once at startup so a bad value crashes the process immediately, and never commit secrets.

Why this exists

Every service needs values that differ by environment: which database to talk to, which API key to use, whether debug mode is on. The fastest thing to do is hardcode them.

DATABASE_URL = "postgres://prod-db.internal/app"
ANTHROPIC_API_KEY = "sk-live-abc123"
DEBUG = False

This fails in concrete ways:

  • You cannot deploy the same artifact twice. To point at staging you must edit the source and rebuild, so staging and production are no longer the same code.
  • A secret lives in version control forever. Even after you delete the line, it stays in git history. Anyone with repo access has the key.
  • Rotation is a redeploy. Changing a key means a code change, a build, and a release.
  • One environment’s value leaks into another. The classic incident: a staging service pointed at the production database because someone edited the wrong constant.
  • Nothing is validated. A typo in a port number becomes a crash twenty minutes later, in the middle of a request, instead of at startup.

Configuration management is the discipline of separating what the code does from what this environment is. The code is identical everywhere. The environment supplies the differences.

Note:

The one-sentence purpose. Configuration management loads environment-specific values, validates them once, and hands the rest of the program a typed, trustworthy object.

Start from zero

WordPlain meaning
Configuration (config)Non-secret settings: URLs, ports, timeouts, log levels, feature flags.
SecretA value whose disclosure causes harm: passwords, API keys, tokens, private keys.
Environment variableA key/value pair provided by the operating system to a process, read with os.environ. Always a string.
.env fileA plain text file of KEY=value lines, loaded into the process environment for local development.
Twelve-factor appA set of rules for services, one of which is “store config in the environment.”
Precedence orderThe rule for which source wins when the same setting appears twice.
Fail fastCrash at startup on bad config, rather than failing later during a request.
Feature flagA config-driven switch that turns a feature on or off without deploying new code.
Config driftEnvironments slowly diverging because values were changed by hand and never recorded.
Secret injectionSupplying a secret at runtime from a vault, a mounted file, or a platform, not from the image.
BaseSettingsThe pydantic-settings class that reads environment variables into typed, validated fields.
SecretStrA Pydantic type that holds a secret but prints as ********** to prevent accidental leaks.
CoercionConverting a string like "true" or "8080" into a bool or int using the field’s type.

Two ideas cause most confusion, so pin them down now:

  • Config vs secret is about risk, not size. A database URL can be config. The database password inside it is a secret.
  • Source vs precedence is about where a value came from and which one wins. Both matter because local development, CI, and production use different sources.

The twelve-factor idea is worth stating plainly. Factor three of the twelve-factor app says: store config in the environment. The reasoning is that config varies between deploys, code does not, and an environment variable is the one mechanism every runtime already provides. A settings library is a friendlier front door to that idea, not a replacement for it.

The core idea

Think of a recipe and ingredients. The recipe is the code: the same steps everywhere. The ingredients are configuration: different kitchens, different quantities. A good cook reads every ingredient once, at the start, checks they exist, and only then begins.

flowchart LR
    A["OS environment<br/>DATABASE_URL=..."] --> M{"Settings object"}
    B[".env file<br/>(local only)"] --> M
    C["Init arguments<br/>(tests)"] --> M
    D["Mounted secret files<br/>(vault/k8s)"] --> M
    M --> E["Type coercion<br/>'true' -> True"]
    E --> F["Validation<br/>missing or bad -> crash now"]
    F --> G["Typed settings<br/>settings.database_url: str"]
    G --> H["Injected into the app<br/>no scattered os.getenv"]

The precedence order for pydantic-settings, highest priority first, is fixed by its source code:

1. init arguments          Settings(database_url="sqlite://")   <- tests
2. environment variables   APP_DATABASE_URL                     <- production
3. .env file               APP_DATABASE_URL=...                 <- local dev
4. secrets directory       /run/secrets/database_url            <- vault mounts
5. field defaults          database_url: str = "sqlite://"      <- safe fallback

Each source is consulted in turn and the first value found wins. That is why a real environment variable beats a .env file, and why a test can override anything by passing an argument. Every one of these rows was verified by running the code.

A one-line mental model: read all config once, at the edge, into one typed object; hand the object inward. No module anywhere else in the program should call os.getenv.

How it works

  1. The process starts with whatever environment the platform gives it. In a container that is the environment: block, a mounted secret, or a value injected by the orchestrator.
  2. The settings class declares every setting as a typed field. database_url: str, debug: bool = False, max_retries: int = 3. Types are documentation and validation at once.
  3. At construction, sources are read in precedence order. Init arguments first, then environment variables, then the .env file, then the secrets directory, then defaults.
  4. Each raw string is coerced to the field’s type. "true" becomes True, "8080" becomes 8080. A value that cannot be coerced is an error, not a silent zero.
  5. Field validators run. They enforce rules a type cannot express, such as “an API key must not be blank.”
  6. Model validators run over the whole object. They enforce cross-field rules, such as “debug must be false in production.”
  7. If anything is invalid, construction raises ValidationError and the process exits. This is fail fast: the bad deploy never serves a single request.
  8. The typed object is passed into the parts of the app that need it, usually once, often cached with lru_cache or wired through dependency injection.

Step 4 is where a settings library earns its keep. os.environ["DEBUG"] = "false" is the string "false", and bool("false") is True, because any non-empty string is truthy. A debug: bool field gives you the value you actually meant.

The syntax you will use

The raw environment, and its traps. Start here so the library makes sense.

import os

url = os.getenv("DATABASE_URL", "sqlite:///local.db")   # default if absent
port = os.environ["PORT"]                                # KeyError if absent
debug = bool(os.getenv("DEBUG", ""))                     # WRONG: bool("false") is True

Everything from the environment is a string. Any conversion is your job.

A typed settings class. This is the standard production shape.

from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_")
    database_url: str
    api_key: str
    environment: Literal["development", "staging", "production"] = "development"
    debug: bool = False
    max_retries: int = 3

APP_DATABASE_URL becomes settings.database_url with the prefix stripped and the field name matched case-insensitively.

Load a .env file for local development.

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
    database_url: str

Real environment variables still win over the file, so the same class works locally and in production.

Nested configuration with a delimiter. Group related settings with a double underscore.

class DB(BaseModel):
    host: str = "localhost"
    port: int = 5432

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_nested_delimiter="__", env_prefix="APP_")
    db: DB = DB()

APP_DB__HOST and APP_DB__PORT fill the nested db model. Flat fields such as db_host simply use APP_DB_HOST.

Keep secrets masked with SecretStr.

from pydantic import SecretStr

class Settings(BaseSettings):
    api_key: SecretStr

settings.api_key.get_secret_value()   # explicit, greppable access
print(settings.api_key)               # **********   (repr shows SecretStr('**********'))

model_dump() and model_dump_json() also mask it, so a careless log.info(settings) does not leak the key.

Validate single fields.

from pydantic import field_validator

@field_validator("api_key")
@classmethod
def key_not_blank(cls, value: str) -> str:
    if not value.strip():
        raise ValueError("api_key must not be blank")
    return value

Validate the whole configuration, including cross-field rules.

from typing import Literal
from pydantic import model_validator

@model_validator(mode="after")
def check_environment(self):
    if self.environment == "production" and self.debug:
        raise ValueError("debug must be false in production")
    return self

Accept a well-known name with aliases. Useful when two platforms use different variable names.

from pydantic import AliasChoices, Field

database_url: str = Field(
    validation_alias=AliasChoices("DATABASE_URL", "APP_DATABASE_URL")
)

Read secrets from mounted files. Each file name is the field name and the file content is the value.

model_config = SettingsConfigDict(secrets_dir="/run/secrets")

The file name must match the field name, including the env_prefix if one is set: with env_prefix="APP_" the file is APP_api_key.

Build once and reuse. Constructing settings on every call re-reads the environment each time; cache it.

from functools import lru_cache

@lru_cache
def get_settings() -> Settings:
    return Settings()

cache_clear() resets it, which tests use to load a different environment.

Wire it into the app. With FastAPI, expose it as a dependency so it is easy to override in tests.

from fastapi import Depends

def get_db(settings: Settings = Depends(get_settings)) -> str:
    return settings.database_url

Examples: simple to real

Example 1 — the string-boolean bug.

import os

os.environ["DEBUG"] = "false"
print(bool(os.environ["DEBUG"]))       # True  <- surprise!

The fix is a typed field: declare debug: bool and the library coerces "false" to False. This one bug has shipped a surprising number of times.

Example 2 — a typed settings object with a default.

from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
    database_url: str
    debug: bool = False
    max_retries: int = 3
    environment: Literal["development", "staging", "production"] = "development"

settings = Settings(database_url="postgres://localhost/app")
print(settings.debug, settings.max_retries)   # False 3

If APP_DATABASE_URL is set and no argument is passed, it is used instead. A call argument always wins.

Example 3 — precedence in action.

# .env contains: APP_DATABASE_URL=postgres://from-dotenv/db
os.environ["APP_DATABASE_URL"] = "postgres://from-env/db"
settings = Settings()
print(settings.database_url)     # postgres://from-env/db  <- env beats file

Remove the environment variable and the .env value is used. Pass Settings(database_url="sqlite://") and that wins over both. The order never changes, so it is safe to rely on.

Example 4 — fail fast on a bad value.

os.environ["APP_MAX_RETRIES"] = "not-a-number"
Settings()
# pydantic_core.ValidationError: 1 validation error for Settings
# max_retries
#   Input should be a valid integer, unable to parse string as an integer

The process exits at startup with a clear message naming the field. Compare that to the alternative: a crash on the first request that needs retries, twenty minutes after deploy.

Example 5 — cross-field validation for production safety.

@model_validator(mode="after")
def check_production(self):
    if self.environment == "production" and self.debug:
        raise ValueError("debug must be false in production")
    if self.environment == "production" and "sqlite" in self.database_url:
        raise ValueError("sqlite is not allowed in production")
    return self

These rules catch configuration that is individually valid but dangerous together, which is exactly the class of mistake that reaches production when only single fields are checked.

Example 6 — a feature flag that is just configuration.

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_")
    enable_streaming: bool = False
    max_agent_steps: int = 10

def handle(settings: Settings):
    if settings.enable_streaming:
        return stream_response(...)

A flag is a config value read at startup. Keeping it in the same validated object means it is typed, documented, and visible next to everything else, instead of hidden in a string comparison somewhere in the code.

In production

  • Never commit secrets. Add .env, *.env.local, and secrets/ to .gitignore, and verify with git check-ignore -v .env. Once a secret is pushed, rotate it; deleting the commit is not enough because forks, caches, and CI logs may already hold it.
  • Remember that the image must not contain secrets. A secret baked into a Docker layer is visible in docker history and in every registry copy. Inject secrets at runtime through the platform, a mounted file, or a vault.
  • Fail fast at startup, never lazily. Construct settings at import time or in the app factory so a bad deploy crashes before it can take traffic. A worker that starts with missing config will fail every request instead of zero.
  • Validate cross-field rules, not just types. “debug must be false in production” and “prod must not use sqlite” are the checks that prevent real incidents.
  • Give defaults that are safe for production. debug: bool = False and log_level: str = "INFO" mean a forgotten variable fails safe. A default of debug: bool = True fails loudly and embarrassingly.
  • Keep the precedence order documented and short. Five sources is already hard to reason about. If a value comes from somewhere surprising, every debugging session costs an hour.
  • Use .env files for local development only. They are a convenience for one machine. In production, the platform’s environment and secret store are the sources, so there is nothing to forget to copy.
  • Never log the settings object. Even with masked secrets, a SecretStr can be unwrapped by mistake. Log specific non-secret fields, or a count of configured values.
  • Treat rotation as a first-class operation. A secret that cannot be rotated without a redeploy will not be rotated after a leak. Read secrets at startup and support a restart, or read them from a vault on a schedule.
  • Watch out for config drift. If someone edits a production variable by hand, it is not in version control and the next deploy silently reverts it. Keep per-environment values in reviewable infrastructure files.
  • Separate config from feature flags operationally. Config is stable and deploy-scoped; flags change during an incident and need a fast, auditable toggle. Mixing them makes both harder to reason about.
  • Do not read os.environ deep inside the code. Scattered reads make it impossible to know what the service needs and impossible to override in a test. Read once, inject inward.

Interview questions

1. What does “store config in the environment” mean, and why?

Answer. It is factor three of the twelve-factor app: settings that differ between deploys live in environment variables, not in the code. That way the same build artifact runs in development, staging, and production, and a value can change without a code change or a rebuild. The environment is the one injection mechanism every runtime supports.

Follow-up: “What about complex or nested config?” A settings library reads environment variables into a typed object, and a single variable can hold JSON for a list or a nested structure. The env var is the transport; the typed object is the interface.

Trap. Thinking env vars are only for secrets. Most config is not secret at all: log level, feature flags, timeouts, URLs.

2. What is the difference between configuration and a secret?

Answer. Configuration is non-sensitive and can be visible in a repository or dashboard: ports, timeouts, feature flags, a database host. A secret’s disclosure causes harm and needs stricter handling: encryption at rest, masked access, rotation, and never entering git. The distinction is about risk, and it decides where a value is stored and who can read it.

Follow-up: “Why does the distinction matter operationally?” Config can be reviewed in a pull request and changed freely. Secrets need a vault or secret manager, an access policy, and a rotation plan, so treating the two the same either over-restricts config or under-protects secrets.

Trap. Labeling everything “secret” so nothing is reviewable, or labeling everything “config” and committing a database password.

3. How does pydantic-settings load a value, and why is it better than os.getenv?

Answer. At construction it reads sources in a fixed precedence order — init arguments, environment variables, .env file, secrets directory, then defaults — coerces each string to the field’s declared type, and runs validators. os.getenv returns an untyped string, has no central declaration of what the service needs, and fails only when the missing value is first used.

Follow-up: “What happens on a bad value?” Construction raises ValidationError naming the field. If you build settings at startup, the process refuses to run.

Trap. Assuming fields read at class-definition time. Values are read when you construct the settings object, so environment changes made before construction are picked up.

4. What is the precedence order, and why does it matter?

Answer. Init arguments beat environment variables, which beat the .env file, which beats the secrets directory, which beats defaults. It matters because every environment needs a different winner: production uses real environment variables, local development falls back to .env, and tests override with constructor arguments, all using one settings class.

Follow-up: “Why do init arguments win?” They are the most explicit, in-code statement of intent, and they are how tests inject a deterministic configuration without touching the process environment.

Trap. Forgetting that a .env file does not override an existing environment variable. Locally you change .env, the old shell variable still wins, and you debug for an hour.

5. Why validate configuration at startup instead of when it is used?

Answer. Fail fast. A bad value discovered at startup stops the deploy before any traffic, with one clear error and one rollback. A bad value discovered mid-request fails some requests, produces confusing partial behavior, and is far harder to attribute. Startup validation turns a runtime bug into a deploy-time error.

Follow-up: “What should validation cover beyond types?” Ranges only valid together, such as min_workers <= max_workers, and environment rules such as “debug is not allowed in production.”

Trap. Treating a schema as enough. Files that are individually valid can still combine into a dangerous configuration, which is what model validators exist to catch.

6. How do you handle secrets in containers?

Answer. Keep them out of the image and inject them at runtime. The usual options are environment variables supplied by the orchestrator from a secret store, secret files mounted into /run/secrets, or the app pulling from a vault at startup. All three keep the secret out of the image layer and out of version control, and all three support rotation by restarting the container.

Follow-up: “Why are mounted files often preferred to environment variables?” Environment variables can leak through logs, crash dumps, and child processes, and they are inherited by everything the process spawns. A file with tight permissions is easier to control, and pydantic-settings reads a secrets directory directly.

Trap. Using build arguments or ENV for secrets. Both are recorded in the image history and visible to anyone who can pull it.

7. What are feature flags, and how do they relate to configuration?

Answer. A feature flag is a configuration value that enables or disables a feature without deploying new code, for example enable_streaming: bool = False. It is configuration, but it changes on a different rhythm: config is set per deploy, while a flag is toggled during an incident or a gradual rollout, and every toggle should be auditable.

Follow-up: “When should you remove a flag?” As soon as the rollout is complete and the old path is deleted. Flags left in code become untested branches and a growing matrix of states.

Trap. Reading flags from random places in the code. Keep them in the validated settings object, or in a dedicated flag service if they must change without a restart.

8. Should you use a .env file in production?

Answer. No. A .env file in production is a file that must be placed correctly, cannot be audited, can be edited by hand, and often ends up copied between environments. Production should take values from the orchestrator’s environment or a secret store, which are owned, reviewed, and rotated by the platform. Keep .env for local development.

Follow-up: “What if the team insists on a file?” Then make it a mounted secret with strict permissions, owned by the platform, never in git, and validated at startup like any other source.

Trap. Committing a .env “for convenience,” even one with fake values. Fake values get replaced by real ones, and the file is already tracked.

Remember this

  • Config changes per environment; code does not. Store it in the environment, not in the source.
  • Read once at the edge, into one typed, validated object, and inject it inward. Do not scatter os.getenv.
  • Precedence is fixed: init arguments, environment variables, .env, secret files, defaults.
  • Fail fast at startup. A bad value should stop the deploy, not a request.
  • Never commit secrets, and inject them at runtime — not into the image, not into git, not into logs.