Environments, configuration and secrets
Lesson 2 of 3
2Environments, configuration and secrets
The big pictureThe database URL on your machine is not its URL in production. Write it in the code and you edit the code per environment, and publish the password with the first push. The fix: the code reads its settings from the environment it runs in.
What the picture shows: One build deployed to every environment. The whole difference is in the variables that come from outside.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
Like one air conditioner installed in different houses: the unit is the same, but each house sets the temperature on its remote. You do not open the unit to change the temperature. And you do not tape the house key to the unit.
The Twelve-Factor principle: configuration lives in environment variables, not code. Locally, a .env file (in .gitignore) with a .env.example of placeholder values in the repository. Read settings in one place (a Settings object), convert types, and validate the required ones at startup (fail fast) instead of crashing on the first request. Pydantic Settings does this automatically. Production secrets come from a secrets manager (GitHub Actions secrets, AWS Secrets Manager, Vault, the platform's secrets), are rotated, and never printed in logs. If a secret leaks: revoke it at once; deleting it from history is not enough.
Before You Start
🐳Docker
The previous lesson.
⌨️The terminal
You know export and echo $VAR.
The Core Idea
1Settings in one place
We read every setting once, convert types, and fail clearly if a required one is missing. Here we pass a dictionary instead of the real environment to see both cases:
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
database_url: str
debug: bool
port: int
REQUIRED = ["DATABASE_URL"]
def load_settings(env):
missing = [name for name in REQUIRED if not env.get(name)]
if missing:
raise SystemExit(f"missing settings: {', '.join(missing)}")
return Settings(
database_url=env["DATABASE_URL"],
debug=env.get("DEBUG", "false").lower() == "true",
port=int(env.get("PORT", "8000")),
)
dev = {"DATABASE_URL": "postgresql://localhost/cafe", "DEBUG": "true"}
print(load_settings(dev))
try:
load_settings({"PORT": "9000"})
except SystemExit as e:
print("startup refused ->", e)os.environ, or use pydantic-settings, which reads the environment and .env and validates the types.2.env files
.env is for your machine only and sits in .gitignore. .env.example in the repository documents the names with dummy values, so a new colleague knows what they need:
# .env.example (committed) DATABASE_URL=postgresql://cafe:change-me@localhost:5432/cafe DEBUG=false PAYMENT_API_KEY= # .gitignore .env
.env and deleting it in a later commit. The secret is still in history, and bots scanning GitHub pick it up within minutes. Revoke the key and issue a new one.3Secrets in production
Deployment platforms and CI tools store secrets encrypted and inject them as environment variables at run time. Do not print them in logs, do not send them in error messages, and give each service the least permissions it needs.
Worked example
Problem: the app runs in production for an hour, then crashes on the first payment request: KeyError: PAYMENT_API_KEY. Analysis: the variable is read only inside the payment function, so nobody noticed it was missing at deploy. Fix: read every setting in Settings at startup. Now the deploy itself fails with "missing settings: PAYMENT_API_KEY" before any user arrives.
Myth vs Reality
| Myth | Reality |
|---|---|
| The repository is private, so secrets in it are safe. | Everyone with access (and every tool connected to it) sees the secret, and private can become public. |
| A settings file per environment inside the code is a good idea. | It mixes secrets with code. Settings come from the environment; the code is one. |
Test your understanding
Where do you put the production database URL?
Secrets stay outside the repository and are injected at run time.
You committed an API key by mistake. First step?
The key may already be copied, so revoking is the protection.
Why validate settings at startup?
Failing early is cheaper and clearer.
Quick review cards
Check Your Understanding
What separates .env from .env.example?
The first holds real values outside the repo; the second holds names with dummy values inside it.
How do you pass settings to a container?
With -e or --env-file in docker run, or environment/env_file in Compose.
What is the risk of printing all settings at startup?
Secrets end up in logs that many people read.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs2
Done with this lesson?
Sign in to save your progress.