Topic 15.3
Configuration, Environment Variables and Secrets
In one line
Settings that change between your laptop, a test server and production (and secrets such as API keys) should live outside the code, in environment variables, and be read once into a typed settings object. A secret that is committed to Git must be treated as stolen.
Think of it like this
A house key. The house (your program) is the same for everyone, but the key (a password or API key) is not part of the house. You do not carve it into the front door where every visitor can read it. You keep it in your pocket and hand it over only where needed. Configuration is the settings you may change without rebuilding the house; secrets are the keys.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Configuration
- Settings that can differ between machines or stages, such as a database address or a log level.
- Secret
- A sensitive value such as a password, token or API key that must not be shared.
- Environment variable
- A named text value the operating system hands to a program when it starts.
- os.environ
- Python's dictionary-like view of the environment variables. All values are strings.
- .env file
- A local text file of NAME=value lines used to set environment variables for development.
- .gitignore
- A file listing paths that Git must never track, such as .env.
- Twelve-factor app
- A set of habits for services, including keeping configuration in the environment.
- Production
- The live system that real users use, as opposed to your own laptop or a test setup.
- Masking
- Showing only a small part of a secret, so logs help debugging without leaking it.
- Rotate / revoke
- To replace a secret with a new one, or to cancel an old one so it stops working.
Step by step
01Read a setting from the environment
os.environ looks like a dictionary. Reading a name that is not set with square brackets raises KeyError; .get returns None or your default instead.
import os
api_key = os.environ["LLM_API_KEY"] # required: KeyError if missing
debug = os.environ.get("DEBUG", "0") # optional: "0" if missing
timeout = float(os.environ.get("TIMEOUT_SECONDS", "10")) # convert the string02Everything is a string
The environment cannot hold numbers or booleans, only text. os.environ.get("DEBUG", "0") is the string "0", which is truthy in Python! if os.environ.get("DEBUG"): would be true for "0" and "false". Always convert and compare on purpose.
debug = os.environ.get("DEBUG", "0").lower() in {"1", "true", "yes"}03Set variables in your shell
For a quick run you set the variable on the same line as the command. On Windows PowerShell you set it first.
# Linux / macOS
LLM_API_KEY=fake-key-abcdef123456 python main.py
# PowerShell
$env:LLM_API_KEY = "fake-key-abcdef123456"
python main.py04A .env file for local work
Put local settings in .env, keep it out of Git, and commit a copy with fake values. With python-dotenv, load_dotenv() copies the file's lines into os.environ unless a real variable is already set, so the real environment always wins.
# .env (never commit this file)
APP_ENV=dev
LLM_API_KEY=fake-key-abcdef123456
TIMEOUT_SECONDS=1505Protect it with .gitignore
Add the ignore rule before the first commit. Then check that Git really ignores it.
echo ".env" >> .gitignore
git check-ignore -v .env # prints the rule that ignores it
git status # .env must not be listed as untracked06One typed settings object
Gather every setting in one dataclass, build it at start-up, and fail with a clear message listing everything that is missing. The code in the second runnable example does this. Everything below it receives the settings object as a parameter, so tests can pass fake settings without touching the real environment.
Try it yourself
- 1
Predict the error
In the first example remove the line that sets
APP_ENVand run it. Predict the exact exception before you run:KeyError: 'APP_ENV'. - 2
Break the number
In the second example change the
.envline toTIMEOUT_SECONDS=fast. Thefloat(...)call fails withValueError. Settings code is the right place to catch that and report which variable was bad. - 3
Real wins over file
Before the loading loop add
os.environ["APP_ENV"] = "prod". The printedapp_envbecomesprod, becausesetdefaultonly fills names that are not set yet.
Code & diagrams
The key is fake. The mask function is what you would use before putting a key into a log line.
Expected output
APP_ENV: dev
DEBUG with default: 0
DEBUG is None? True
KeyError for missing variable: 'DATABASE_URL'
key in a log line: fake...56The program writes a fake .env file, reads it, and builds settings. The first attempt fails with a clear list of what is missing. `@dataclass` and `@classmethod` are explained in Topics 8.8 and 8.3; `setdefault` (Topic 4.4) sets a key only if it is not there yet.
Expected output
before loading: missing environment variables: APP_ENV, LLM_API_KEY
app_env: dev
timeout: 15.0
Settings(app_env='dev', api_key='fake-key-abcdef123456', timeout_seconds=15.0)Not runnable here: these packages are not in the browser. python-dotenv reads .env for you; pydantic-settings does the typed conversion and the missing-variable check in one class (Topic 15.4 explains the BaseModel it builds on).
# pip install python-dotenv pydantic-settings
from dotenv import load_dotenv
from pydantic_settings import BaseSettings, SettingsConfigDict
load_dotenv() # copy .env into os.environ (real variables win); optional here,
# because env_file below already makes Settings read .env itself
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
llm_api_key: str # required: reads LLM_API_KEY
timeout_seconds: float = 10.0
debug: bool = False # "true", "1", "yes" are converted for you
settings = Settings() # raises ValidationError listing what is missingBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Hard-code the key
Paste a key straight into the source file and commit it.
API_KEY = "fake-key-abcdef123456" # BAD: now it lives in Git history foreverMyth vs fact
Myth
A private repository is a safe place for keys.
Fact
Private repositories get cloned, forked, shared with contractors and leaked. Keys belong in the environment or a secrets manager, not in any repository.
Myth
If I delete the committed key in the next commit, the problem is gone.
Fact
The key is still in the history. Treat it as leaked: revoke it and issue a new one.
Myth
Environment variables are encrypted and totally safe.
Fact
They are plain text visible to the process and its children (and sometimes to other users or crash reports). They are far better than source code, but a managed secrets store with access control and audit logs is stronger for production.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Production platforms offer secrets managers (cloud provider vaults, Kubernetes Secrets, the secret stores in CI systems) that inject values as environment variables or mounted files at run time, with access control, audit logs and rotation. See the DevSecOps course (
/devsecops) for scanning, rotation and supply-chain defences. - ▸
Do not pass secrets on a command line (
--api-key abc): other users on the machine can see command lines withps, and shells record history. Use environment variables or files with tight permissions. - ▸
Python never prints a variable's value in a traceback, but frameworks and
reprof settings objects can. Mark secret fields so they are hidden:pydantic.SecretStrprints as**********and needs.get_secret_value()to reveal the text. - ▸
Config also means feature flags and model names. Keeping the model name in config lets you switch an AI model without a code release.
- ▸
Look at the last line of the second runnable example:
print(settings)shows the fake key, because adataclassreprprints every field. The same thing happens when a settings object lands in a log line or an error report. Declare secret fields withfield(repr=False)on a dataclass, or useSecretStrin Pydantic, so the leak cannot happen by accident. - ▸
An environment variable is not private from everything that can inspect the process. On Linux, a process running as the same user (and root) can read
/proc/<pid>/environ.docker inspectshows the-evalues of a container to anyone with access to the Docker daemon, and child processes inherit the whole environment, including keys they do not need. For high-value secrets prefer a mounted file with tight permissions or a secrets manager, and when you start a subprocess pass it a trimmedenv=dictionary (Topic 15.8). - ▸
Details that cause real bugs: on Windows environment variable names are case-insensitive (Python upper-cases them in
os.environ), on Linux and macOS they are case-sensitive.os.environ["X"] = 1raisesTypeErrorbecause values must be strings. An empty string is a set variable, soos.environ.get("KEY", "default")returns""and not the default: checkif not valuewhen empty should count as missing.pydantic-settingsmatches names case-insensitively by default, and an environment variable beats the.envfile, which beats the field default.
Remember this
- 1
Configuration is anything that differs between places the program runs: the database address, the log level, a timeout, the name of the AI model. Secrets are the sensitive subset: passwords, API keys, tokens, private keys. Both should stay out of your source code, so one copy of the program can run anywhere and so keys never end up in version control. (Version control, such as Git, remembers every past version of your files, so anything saved in it stays there.) Production means the live system that real users use, as opposed to your laptop.
- 2
Environment variables are named text values that the operating system gives every program when it starts, like a small dictionary of settings. Python exposes them as
os.environ, which behaves like a dictionary of strings.os.environ["NAME"]raisesKeyErrorwhen the name is missing (good for required settings, because it fails loudly).os.environ.get("NAME", default)returns a fallback (good for optional ones). Every value is a string, so you convert numbers and booleans yourself:int(...),float(...),value.lower() in {"1", "true", "yes"}. - 3
Setting a variable differs per system. In a Linux or macOS shell:
export LLM_API_KEY=.... In PowerShell:$env:LLM_API_KEY = "...". In Docker or a cloud platform you set them in the service's settings. A variable set in one terminal window is not visible in others. A child process (a program started by another program) gets a copy of its parent's variables. - 4
A **
.envfile** is a plain text file ofNAME=valuelines for local development, so you do not retype exports each time. A small helper (the packagepython-dotenvis the usual one) reads it intoos.environwhen the program starts. The file holds secrets, so it must be listed in.gitignoreand never committed. Commit a.env.examplewith fake placeholder values instead, so teammates know which names to set. - 5
A secret that reaches Git is leaked, even if you delete it in the next commit, because history keeps it and bots scan public repositories within minutes. The defence is: keep keys out of code from the start, add
.envto.gitignorebefore your first commit, run a secret scanner in CI (the automatic checks that run each time you push code), and if a key leaks, revoke and replace it immediately. Do not just remove it from the file. Also never print keys in logs or error messages; log a masked form (fake...56) or nothing. - 6
Read all settings once at start-up into one typed object. Use a
dataclass(Topic 8.8, a class that is mostly a bundle of named fields) or a Pydantic settings class (Topic 15.4). Check that required ones exist, and pass that object to the code that needs it. Then a missing key stops the program at the very start with a clear message, not ten minutes in at the first model call. This follows the twelve-factor app idea ("store config in the environment"). It is also how every AI project handlesOPENAI_API_KEY,ANTHROPIC_API_KEYand similar: the SDK reads the variable for you, and your code never contains the key.
Explain it without notes
Why should configuration and secrets live outside the source code?
How do you read a required and an optional environment variable in Python, and what type do you get?
What should you do if an API key is accidentally committed to Git?
What is a .env file, and how do you keep it safe?
Practice
Write get_bool(name, default=False) that reads an environment variable and returns True for 1, true or yes in any capitals, False for anything else, and default when unset.
Write require(*names) that returns a dictionary of the named environment variables, and raises a RuntimeError naming every missing one.
Write mask(secret) that keeps the first 3 and last 2 characters of a key longer than 8 characters, and returns *** for shorter ones.
Trade-offs
- ↔
Plain environment variables versus a secrets manager. Environment variables are simple and work everywhere. A secrets manager adds access control, audit trails and rotation, but needs set-up and an extra dependency. Start simple locally, use a manager in production.
- ↔
**Reading
os.environdirectly versus a settings class.** Direct reads are quick for a tiny script. A settings object gives you one place for names, types, defaults and validation, which pays off as soon as there are more than three settings.
Done when you can
Done when you can read required and optional environment variables and convert them to the right types.
Done when you can explain why a committed secret is leaked even after deletion.
Done when
.envis in your.gitignorebefore your first commit, and a.env.exampleexists.Done when you can build a settings object at start-up that fails clearly if something is missing.
Done when you never print or log a whole key and can write a masking helper.