Skip to main content

Configuration, secrets & structured logging

Level 4: Level 4: Engineeringhard20 minconfigurationsecretsstructured-loggingtwelve-factor

Load typed config from the environment, keep secrets safe, and log structured records.

Config lives in the environment, not in your code

A service that hard-codes its port, database URL, or feature flags can only run in one place. Twelve-factor apps push all of that into environment variables so a single build artifact runs unchanged in dev, staging, and prod. You change behavior by changing the environment, never by editing and redeploying code. That is also why the demo's load_config takes an env dict as an argument instead of reaching into os.environ directly: passing the environment in keeps the function pure and trivial to test.

Environment values are always strings

os.environ (and the env dict here) maps strings to strings. There are no ints, bools, or lists inside it. Reading config is therefore two steps: supply a default for missing keys, then coerce the string into the type you actually want.

port = int(env.get("PORT", "8000"))          # "9000" becomes 9000
debug = env.get("DEBUG", "false").lower() == "true"
Check yourself
A teammate finds the .lower() comparison verbose and simplifies it to debug = bool(env.get('DEBUG', 'false')). Production has DEBUG set to the string false. What is debug in production?

env.get("PORT", "8000") returns the default only when "PORT" is absent. If the key exists, you get its string value and must convert it yourself.

Secrets: record presence, never the value

API keys, tokens, and passwords come from the environment too, but they must never appear in source control or in logs. The safe pattern is to log whether a secret is configured, not what it is:

has_secret = "SECRET" in env    # a True/False flag is safe to emit; the value is not

"SECRET" in env tests key membership, so it stays False until the key exists and never reads the value.

Check yourself
During an incident nobody can tell which API key a pod was started with. Which line is actually safe to add to the startup log?

Structured logging

Log machine-readable records, not sentences. Key/value fields let a log system filter and aggregate (for example, find every startup where debug was True):

import logging
logger = logging.getLogger(__name__)
logger.info("startup", extra={"port": port, "debug": debug})

The extra fields attach to the log record, but they only surface if your handler uses a structured (often JSON) formatter. The default formatter prints just the message text.

Check yourself
A deploy template writes PORT= with nothing after the equals sign, so the variable exists with an empty value. Your code is port = int(env.get('PORT', '8000')). What does the service do at boot?

Pitfalls

  • bool("false") is True. Every non-empty string is truthy, so never coerce a flag with bool(...). Compare the lowered string explicitly, as above.
  • int(env.get("PORT", "8000")) raises ValueError if PORT is set to "" or "abc". A present-but-empty variable is not the same as a missing one, and .get only defends against the missing case.
  • Reusing a reserved field name in extra (like message or name) raises KeyError. Choose your own keys.
Check yourself
A reviewer asks why you build one Config at startup instead of calling os.environ.get wherever a value is needed. What is the strongest answer?

Interview nuance: interviewers probe why config should be loaded once, at startup, into a typed object rather than read from os.environ all over the codebase. Loading once gives you a single place to validate and fail fast, makes the code testable (you inject an env dict, exactly like this exercise), and guarantees every request sees one consistent snapshot instead of values that could change mid-run.

Worked example (Python)
def load_config(env):
    port = int(env.get("PORT", "8000"))
    debug = env.get("DEBUG", "false").lower() == "true"
    return {"port": port, "debug": debug, "has_secret": "SECRET" in env}


print(load_config({"PORT": "9000", "DEBUG": "true", "SECRET": "x"}))

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement load_config(env) to turn an env dict into {"port": int, "debug": bool, "has_secret": bool}. Default PORT to 8000 and DEBUG to off. debug is True only when DEBUG is "true" (any case). has_secret records whether "SECRET" is present (never its value).

load_config({}) is {"port": 8000, "debug": False, "has_secret": False}.

3 hints and 3 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement load_config(env) in config/settings.py: use the read-only DEFAULTS for PORT and DEBUG, coerce PORT to int and DEBUG to bool ("true" case-insensitive), and set has_secret from whether "SECRET" is present, never its value. Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.