Configuration the Twelve-Factor Way
Read configuration from the environment, keep defaults safe, and refuse to start when a required setting is missing.
PORT = int(os.environ.get("PORT", "8000")) # os.getenv("PORT", "8000") is identicalConfiguration is everything that differs between your laptop and production: database URLs, API keys, ports, feature flags, log levels. The twelve-factor rule is to keep it in the environment rather than in the code, so one build artefact runs in every environment and no secret is ever committed.
Reading it
os.environ is the mapping; os.getenv(key, default=None) is a thin wrapper, so os.getenv("PORT", "8000") and os.environ.get("PORT", "8000") are the same call written two ways. Use os.environ["DATABASE_URL"] when there is no acceptable default — the KeyError is the point.
Every value is a str. int(os.environ["PORT"]) for numbers, and parse flags explicitly, because bool("0") and bool("false") are both True — casting a flag gives you a setting that is on no matter what you set it to.
Safe defaults
A default is only acceptable when the safe choice is obvious: LOG_LEVEL=INFO, PORT=8000, DEBUG off. A default database URL pointing at localhost, or a default that disables authentication, is a production incident waiting for someone to forget a variable.
Fail at startup, not at midnight
Load and validate the whole configuration once, at import time, and raise listing every missing key together. The alternative is os.getenv("STRIPE_KEY") returning None at boot, travelling through the system as None, and failing as an unexplained 401 the first time someone checks out. Deploy tooling notices a container that will not start; it does not notice one that starts broken.
For non-secret file-based settings, tomllib (added in 3.11) reads TOML from a file opened in binary mode, and the environment still wins for anything that differs per deployment.
Example
When to use it
- Running the same container image in staging and production with different variables.
- Making a deploy fail immediately when someone forgets to set a new secret.
- Removing a hard-coded localhost database URL from a service that is about to ship.
- Turning a debug flag on in staging without a code change.
More examples
One settings object, built once
The shape to copy into a real service. Because it runs at import, a missing variable breaks the container's first start, where your deploy pipeline can see it — and frozen means no code path can mutate configuration halfway through a request.
import os
from dataclasses import dataclass
@dataclass(frozen=True) # frozen: nothing reconfigures itself at runtime
class Settings:
database_url: str
api_key: str
port: int = 8000
debug: bool = False
def _flag(name, default="0"):
return os.environ.get(name, default).strip().lower() in ("1", "true", "yes", "on")
def load_settings():
missing = sorted(k for k in ("DATABASE_URL", "API_KEY") if not os.environ.get(k))
if missing:
raise RuntimeError("missing environment variables: " + ", ".join(missing))
return Settings(
database_url=os.environ["DATABASE_URL"],
api_key=os.environ["API_KEY"],
port=int(os.environ.get("PORT", "8000")),
debug=_flag("DEBUG"),
)
settings = load_settings() # module level: import fails, so the process failsThe same thing with pydantic-settings
Worth the dependency on anything bigger than a script: you get types, coercion and one readable error. The env_file line is the part to be careful with — it is easy to let a .env become the real source of production config, which is exactly what this lesson is against.
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# Reads DATABASE_URL, API_KEY, PORT, DEBUG from the environment. A field
# with no default is required, and a missing one raises ValidationError
# naming every absent key at once.
database_url: str
api_key: str
port: int = 8000
debug: bool = False # "0"/"false"/"no" parse correctly here
log_level: str = Field(default="INFO")
model_config = SettingsConfigDict(env_file=".env", env_prefix="")
settings = Settings() # raises at import if the environment is incomplete
# .env is a local-development convenience. It belongs in .gitignore, and the
# deployed container should get real environment variables, not a file.Non-secret defaults in a file, secrets in the environment
The split that works in practice: structure and tuning in a version-controlled file, credentials and per-environment values in the environment. tomllib is read-only by design — there is no dump, so use a library if you need to write TOML back out.
import os
import tomllib # standard library since Python 3.11
from pathlib import Path
# tomllib.load requires a BINARY file object; opening in text mode raises
# TypeError, which is the first thing everyone hits.
with Path("config.toml").open("rb") as fp:
file_config = tomllib.load(fp)
# config.toml holds things that are safe to read in a repository:
# [service]
# name = "orders"
# timeout_seconds = 10
settings = {
"name": file_config["service"]["name"],
"timeout": float(file_config["service"]["timeout_seconds"]),
# The environment overrides the file, and secrets only ever come from here.
"api_key": os.environ["API_KEY"],
}
print(settings["name"], settings["timeout"])
Discussion