Configuration and Environments
The same container image runs unchanged in Marek's shell, on staging and on api-01; only its configuration differs. The twelve-factor rule is that config comes from the environment. What this book adds is that it is read once, at startup, into a typed object that fails loudly if anything is missing. Stagedoor's first version read os.environ in the middle of requests, in 14 places, and a variable that was misspelled in the staging deployment was discovered by the first buyer to reach the checkout route, as a 500, forty minutes after the deploy had reported success.
Chapter 3 named the environment as one of the boundaries where bytes enter the process, and this topic gives it the parser it deserves. The config object is the third input to build_service in Topic 20, after nothing and before everything; the whole of the process's dependency graph is constructed from it. The topic is what the object contains, how it is read, and the short list of things it must never contain.
What Configuration Is
Everything that varies between deployments and nothing that does not. The database URL, because staging and production point at different hosts. The Redis URL. Payrail's base URL and key, because staging talks to Payrail's sandbox. The pool size, because the worker wants 5 and the API wants 20. The log level, because a developer's shell wants DEBUG and production wants INFO. The hold duration, if operations may need to tune it for one event without a deploy. Stagedoor's config has 10 fields, and each one is there because two environments disagree about it.
A value that is the same everywhere is a constant in code, not config. The seat label pattern does not change between staging and production, and making it configurable means a deployment can break the parser with a typo in a variable that nobody thought to test. The list of 10 is the list of things that differ; an eleventh that is added "in case" is a twelfth way for two environments to drift apart without a commit recording it.
Read Once, Typed, at Startup
A Config model, built on pydantic-settings, is parsed from the environment as the first line of build_service. Each field has a type, and the type is the parser: a PostgresDsn that is not a valid Postgres URL fails, an int that is "twenty" fails, a field with no default and no variable fails. The failure is a validation error listing every wrong or missing field at once, raised before the pool is opened and before the port is bound. The demonstrated example is that model.
class Config(BaseSettings): model_config = SettingsConfigDict(env_file=".env") # the developer's shell only; gitignored database_url: PostgresDsn # required: no default, chosen per deployment redis_url: RedisDsn payrail_url: HttpUrl payrail_key: SecretStr # repr() prints '**********' pool_size: int # required: 20 on the api, 5 on the worker payrail_timeout_ms: int = 3000 # the same everywhere unless overridden hold_minutes: int = 10 log_level: str = "INFO" port: int = 8000 env_name: str = "local" # read exactly once: the startup log line async def build_service() -> Service: cfg = Config() # raises here, listing every missing field, before the port is bound log.info("starting", env=cfg.env_name, pool=cfg.pool_size, payrail=str(cfg.payrail_url)) ...
The model reads as the deployment's contract. Five fields have no default and must be set, and a process started without PAYRAIL_KEY stops on the first line of build_service with a message naming the field, which is where a config error should stop it: before it has bound the port, before the load balancer has marked it healthy, before a buyer can reach it. Five fields have defaults that hold in every environment unless overridden. The key is a SecretStr, so the startup log line that prints the config prints ten asterisks for it, which is Chapter 11's rule that a secret never touches a log, enforced by the type. And the missing variable that took Stagedoor forty minutes to find under the old code now takes zero: the deploy fails, and the previous version keeps serving.
The Environment, Not a File
Variables are set by the deployment: the container's environment on api-01, the job definition for the worker, the shell for a developer. Not a config.yaml checked in with a development:, a staging: and a production: section. A file per environment is a file that drifts: the staging section gains a field the production section does not, and the difference is discovered the way differences between environments are always discovered. And a file with a production: section will, within a year, contain the production database password, committed, and Chapter 11 is about why git history does not forget.
The one file the book allows is .env in the developer's working directory, read by the settings model when present and ignored when not, and it is in .gitignore from the first commit. It holds the developer's local database URL and a Payrail sandbox key, and nothing that would work anywhere else. A .env.example with the variable names and no values is committed beside it, so a new engineer knows which 10 to set without reading the model.
Defaults and Their Danger
A default is right for a value that is the same in every environment unless somebody overrides it. PORT=8000: every deployment listens there unless told otherwise, and a missing variable means 8000, which is correct. PAYRAIL_TIMEOUT_MS=3000: the number Chapter 7 chose, and staging has no reason to differ. A default is wrong for a value that must be chosen. The database URL has no correct default, and the tempting one, localhost, is how a staging process started with a missing variable connects to a database on the wrong machine, or in the worst case a developer's laptop that happens to be reachable.
The pool size is the case that decides the rule. A default of 20 looks harmless, and it is the API's correct value. The worker wants 5, and a second API instance with the default, plus the four uvicorn processes per host that Chapter 1 counted, is 160 connections against a max_connections of 200 before the worker has connected at all. The number depends on what else is connected to the same database, which only the deployment knows, so the field has no default and each deployment writes its number down.
Environments Are Not Code Paths
if ENV == "production" in a handler is a second codebase that is only ever tested in production. Stagedoor's old checkout had if ENV != "production": skip_payment(), which meant the payment path had never run in staging, and the first time a Payrail timeout was handled by the code that handled it was on the night of the spring on-sale. Behaviour differs by config values, never by environment name: staging skips nothing and talks to Payrail's sandbox, because PAYRAIL_URL points there, and the timeout path runs in staging every time the sandbox is slow.
The one legitimate read of the environment's name is the log line at startup, which says env=staging so that the person reading a log knows which deployment wrote it. Chapter 13 puts the same value on every metric as a label. Nothing in the domain, the storage layer or a handler reads it, and the linter of Topic 19 could enforce that too, though in Stagedoor the field is simply not passed below build_service.
Reloading
Stagedoor does not reload configuration at runtime. A changed value is a new deployment and a restart, and Chapter 11's graceful shutdown makes a restart cost 30 seconds of draining and no dropped requests, so the price of "just restart" is low enough that a hot-reload mechanism buys nothing. The alternative, a config service that the process polls at runtime, is a dependency the process must survive losing, with its own timeout, its own fallback and its own failure mode when half the instances have the new value and half the old. That is a real design with real uses, feature flags among them, and the book names it as a later choice rather than a default. A service with one config object built once has one way to be misconfigured, and it is visible at startup.
os.environ["X"]in a handler — the missing variable is discovered by the first request to that route, as a 500, forty minutes after a deploy that reported success.- A committed
config.yamlwith aproduction:section — the production database password is in git history forever, and rotating it does not remove it from every clone. - Environment-name branches —
if ENV != "production": skip_payment(), a payment path that has never run in staging, and the day staging is renamed and starts charging real cards. - Defaults for values that must be chosen — a default pool size of 100, which across the eight API processes wants 800 against a database that allows 200, and the worker cannot connect at all.
- A default database URL of
localhost— a staging process started with a missing variable connects to whatever is listening there, and the first symptom is data in the wrong place. - Config read lazily on first use — the misspelled Redis URL is found by the first request that needs the cache, which at on-sale is the first request.
- Write one typed config model, parse it once as the first line of
build_service, and let a missing or malformed field stop the process before the port is bound. - Take config from the environment, keep a gitignored
.envfor the developer's shell and a committed.env.examplewith names only, and nothing else. - Give defaults only to values that are legitimately the same everywhere, and leave every URL, key and pool size required.
- Make behaviour depend on config values, never on the environment's name, and read the name exactly once for the startup log line.
- Type every secret as
SecretStrso that printing the config cannot print the key.
@ConfigurationProperties, Node dotenv and convict, Viper (Go) the same typed-object idea per stackThe Twelve-Factor App the source of "config in the environment"Vault and the cloud secret managers where the values config must not hold live instead, in Chapter 11Knowledge Check
Which of these is configuration rather than a constant in code, by the book's test?
- Payrail's base URL, because staging and production point at different hosts
- The seat label pattern, because a future venue might use a different label format
- The Problem Details error shape, because operations might want a shorter body
- The maximum page size of 200 rows, because a large organizer might need more
PAYRAIL_KEY is missing from a staging deployment. Where should the process stop, and why there?
- At the first checkout request, with a 500 that names the missing variable in the log
- At startup, before the port is bound, so the deploy fails and the old version keeps serving
- When the Payrail client is first constructed, after the pool has opened and the port is bound
- At the first health check, which probes Payrail and reports the process as unhealthy to the balancer
Why is if ENV != "production": skip_payment() described as a second codebase?
- The branch adds a comparison to every checkout, which under 3,000 requests a second is measurable
- The environment name is read at startup, which violates the rule that config is read only in handlers
- The payment path only ever runs in production, so staging has tested a program that does not charge
- The import linter cannot see the branch, so the layer rule is broken without a failing build
Which value may carry a default in Stagedoor's config, and which must not?
- The database URL may default to localhost; the port must be chosen per deployment
- The Payrail key may default to the sandbox key; the log level must be set per deployment
- The pool size may default to 20; the Payrail timeout must be chosen per deployment
- The port may default to 8000; the pool size must be chosen and written down per deployment
You got correct