Command Palette

Search for a command to run...

Hectal
PHASE 15Intermediate ~28 min· topic 3 of 10

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.

main.pywhole filepython
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 string

02Everything 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.

main.pywhole filepython
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.

terminalwhole filebash
# Linux / macOS
LLM_API_KEY=fake-key-abcdef123456 python main.py

# PowerShell
$env:LLM_API_KEY = "fake-key-abcdef123456"
python main.py

04A .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.

.envwhole filebash
# .env  (never commit this file)
APP_ENV=dev
LLM_API_KEY=fake-key-abcdef123456
TIMEOUT_SECONDS=15

05Protect it with .gitignore

Add the ignore rule before the first commit. Then check that Git really ignores it.

terminalwhole filebash
echo ".env" >> .gitignore
git check-ignore -v .env     # prints the rule that ignores it
git status                   # .env must not be listed as untracked

06One 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.

One typed settings objectdiagram
Rendering diagram…

Try it yourself

  1. 1

    Predict the error

    In the first example remove the line that sets APP_ENV and run it. Predict the exact exception before you run: KeyError: 'APP_ENV'.

  2. 2

    Break the number

    In the second example change the .env line to TIMEOUT_SECONDS=fast. The float(...) call fails with ValueError. Settings code is the right place to catch that and report which variable was bad.

  3. 3

    Real wins over file

    Before the loading loop add os.environ["APP_ENV"] = "prod". The printed app_env becomes prod, because setdefault only fills names that are not set yet.

Code & diagrams

os.environ: required, optional and masked New tab

The key is fake. The mask function is what you would use before putting a key into a log line.

Sign in to run this example in your browser.

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...56
A .env file and a typed Settings object New tab

The 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.

Sign in to run this example in your browser.

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)
python-dotenv and pydantic-settings (need pip install)python

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 missing

Break 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.

main.pywhole filepython
API_KEY = "fake-key-abcdef123456"   # BAD: now it lives in Git history forever
terminal
$ git log -p --all -S fake-key-abcdef123456
── what you'll see ──
commit <a hash>
add client
+API_KEY = "fake-key-abcdef123456"

Myth 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 with ps, 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 repr of settings objects can. Mark secret fields so they are hidden: pydantic.SecretStr prints 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 a dataclass repr prints every field. The same thing happens when a settings object lands in a log line or an error report. Declare secret fields with field(repr=False) on a dataclass, or use SecretStr in 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 inspect shows the -e values 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 trimmed env= 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"] = 1 raises TypeError because values must be strings. An empty string is a set variable, so os.environ.get("KEY", "default") returns "" and not the default: check if not value when empty should count as missing. pydantic-settings matches names case-insensitively by default, and an environment variable beats the .env file, which beats the field default.

Remember this

  1. 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. 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"] raises KeyError when 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. 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. 4

    A **.env file** is a plain text file of NAME=value lines for local development, so you do not retype exports each time. A small helper (the package python-dotenv is the usual one) reads it into os.environ when the program starts. The file holds secrets, so it must be listed in .gitignore and never committed. Commit a .env.example with fake placeholder values instead, so teammates know which names to set.

  5. 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 .env to .gitignore before 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. 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 handles OPENAI_API_KEY, ANTHROPIC_API_KEY and similar: the SDK reads the variable for you, and your code never contains the key.

Explain it without notes

01

Why should configuration and secrets live outside the source code?

02

How do you read a required and an optional environment variable in Python, and what type do you get?

03

What should you do if an API key is accidentally committed to Git?

04

What is a .env file, and how do you keep it safe?

Practice

01

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.

02

Write require(*names) that returns a dictionary of the named environment variables, and raises a RuntimeError naming every missing one.

03

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.environ directly 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 .env is in your .gitignore before your first commit, and a .env.example exists.

  • 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.