Configuration from the Environment
os.environ is the only config surface a container reliably has; parse and validate the whole of it once, at startup.
os.environ["DATABASE_URL"] / os.getenv("PORT", "8080")A container takes its configuration from its environment rather than from a file baked into the image: one image runs in staging and in production and differs only in what os.environ holds. os.environ is an ordinary mutable mapping of str to str, populated when os is first imported.
Required against optional
os.environ["DATABASE_URL"] raises KeyError when the variable is absent, which is exactly right for a setting with no defensible default. For everything else, os.environ.get("PORT", "8080") returns the default. os.getenv(key, default=None) is the same call spelled shorter — the two forms are interchangeable, and you will see both.
Everything arrives as a string
int(raw) raises ValueError on junk, which is what you want. Booleans are the trap: every non-empty string is truthy, so bool(os.getenv("DEBUG")) is True when DEBUG is the string "false". Compare the lowercased value against an explicit set of accepted words.
Validate once, at startup
Read and convert the whole configuration in one function that returns a frozen dataclass, and call it before the server binds its port. A missing variable then kills the process in its first second, where the orchestrator's restart loop makes it obvious, instead of surfacing as a KeyError inside one request at 3am. Collect every problem and report them together so one deploy fixes three typos.
Secrets
Secrets arrive the same way and must stay out of logs and out of exception text: hold the credential in the config object, and never interpolate it into a URL you then log.
Example
When to use it
- Point the same image at a staging database by changing one variable in the deployment.
- Fail a pod's startup immediately when a required secret was not mounted.
- Turn SQL statement logging on in one environment with no code change.
- Pass a filtered environment to a subprocess instead of handing it every credential you hold.
More examples
Report every problem, not just the first
Raising on the first missing variable means three deploys to fix three typos. Accumulating the errors and exiting once gives the operator the whole list, and SystemExit at import keeps a half-configured process from ever accepting traffic.
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
database_url: str
queue_url: str
workers: int
region: str
def load(env=os.environ):
errors, values = [], {}
def required(key):
try:
return env[key] # KeyError is the honest failure for a required value
except KeyError:
errors.append(f"{key} is required")
return ""
values["database_url"] = required("DATABASE_URL")
values["queue_url"] = required("QUEUE_URL")
values["region"] = env.get("AWS_REGION", "eu-west-1")
raw = env.get("WORKERS", "4")
try:
values["workers"] = int(raw)
except ValueError:
errors.append(f"WORKERS must be an integer, got {raw!r}")
values["workers"] = 0
if errors:
raise SystemExit("configuration rejected:\n " + "\n ".join(errors))
return Settings(**values)
settings = load() # module import time: the pod never reaches the serving loop misconfiguredA child process inherits only what you pass
An explicit env= dict is how you keep a secret out of a child process, but it is also how PATH goes missing and the command becomes FileNotFoundError. Use the copy-and-add form unless you deliberately want the child isolated.
import os
import subprocess
# Assigning to os.environ calls putenv(), so children DO see it...
os.environ["PGAPPNAME"] = "migrator"
# ...but passing env= replaces the environment wholesale. This child gets
# four variables and nothing else - no PATH unless you include it.
child_env = {
"PATH": os.environ["PATH"],
"PGHOST": os.environ["DATABASE_HOST"],
"PGAPPNAME": "migrator",
"LC_ALL": "C.UTF-8",
}
subprocess.run(["psql", "-f", "schema.sql"], env=child_env, check=True)
# The safer middle ground: start from a copy and add to it, so the
# credentials the runtime injected are still there.
env = os.environ.copy()
env["PGCONNECT_TIMEOUT"] = "5"
subprocess.run(["psql", "-c", "select 1"], env=env, check=True)A local .env, without a dependency
Fifteen lines removes a dependency from the one place you least want surprises. The setdefault is the important part: it keeps the file as a fallback rather than an override, so the same code path is safe to leave in a container where the file is absent.
import os
import pathlib
def load_dotenv(path=".env"):
"""Development convenience only. In production the runtime sets the real
environment, and this file does not exist in the image."""
file = pathlib.Path(path)
if not file.exists():
return
for line in file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
key, sep, value = line.partition("=")
if sep:
# setdefault, not assignment: a real environment variable always wins,
# so a stale .env cannot override what CI or the pod injected.
os.environ.setdefault(key.strip(), value.strip().strip("'\""))
load_dotenv()
Discussion