Secrets and configuration
Config is anything that varies by environment. Secrets are the subset that grants access. They need different handling, and conflating them is how credentials end up in a repo.
Config from the environment, typed
from pydantic import SecretStr, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__")
environment: Literal["dev", "staging", "prod"]
database_url: PostgresDsn
api_key: SecretStr # repr/log-safe
request_timeout_s: float = 10.0
max_workers: int = 4
settings = Settings() # fails at startup if anything is missing
Two properties worth having:
- Fail at startup, not at first use. A missing variable should crash the process on boot, where deployment tooling notices, rather than at 3am on the first request that needs it.
SecretStrwon’t print. Itsrepris**********, so a stray log line, an exception traceback, or a debugger dump doesn’t leak the value. You call.get_secret_value()deliberately.
This is 12-factor config: strict separation of config from code, config in the environment.
Where secrets actually live
| Approach | Rotation | Audit | Use |
|---|---|---|---|
| Hardcoded | — | — | never |
.env committed |
— | — | never |
.env local, gitignored |
manual | no | local dev only |
| Env vars from the platform | manual | no | small deployments |
| Secrets manager (AWS/Vault/GCP) | automatic | yes | production |
| Workload identity (IRSA, IAM roles) | no secret exists | yes | best where available |
The best secret is one that doesn’t exist. IAM roles for service accounts, EC2 instance profiles, GCP workload identity federation — the platform issues a short-lived credential to the workload, so there’s nothing to store, rotate, or leak. Reach for this before reaching for a vault.
Where a secret must exist, fetch it at runtime rather than baking it into an image:
@lru_cache(maxsize=1)
def get_db_password() -> str:
resp = boto3.client("secretsmanager").get_secret_value(SecretId="prod/db")
return json.loads(resp["SecretString"])["password"]
Cache it — a secrets-manager call per request is latency and cost you don’t need — but bound the cache so rotation takes effect. A process that caches forever will authenticate with a revoked credential after rotation.
Rotation
The property that separates a real secrets story from a checkbox.
Rotation only works if the application can pick up a new value without a deploy, and if there’s an overlap window where both old and new are valid. Otherwise rotation is an outage.
- Two-secret pattern: provision the new credential, deploy code that accepts both, switch, retire the old.
- Short TTL cache so a rotated secret is picked up within minutes.
- Handle auth failure by re-fetching once before failing, which covers the window where you cached a value that has just been revoked.
Never in the repo
# .gitignore
.env
*.pem
Add automated detection, because discipline fails eventually:
gitleaksordetect-secretsas a pre-commit hook and in CI.- GitHub secret scanning with push protection.
A secret committed to git is compromised even after you delete it. It’s in the reflog, in every clone, and in any fork. The response is to rotate the credential, not to rewrite history and hope. Saying that plainly is the right answer to “you found a key in the repo, what do you do”.
Per-environment configuration
config/
base.yaml # shared defaults
dev.yaml # overrides
prod.yaml
Layer: defaults, then environment file, then environment variables (highest precedence). Keep secrets out of all of them — files hold structure and non-sensitive values, the secrets manager holds credentials.
The environments should differ in values, not in shape. A config key that only exists in production is a code path only exercised in production.
Config in a container
- Don’t bake secrets into images. An image layer is readable by anyone who can pull it, and it persists in the registry.
- Kubernetes
Secretis base64, not encryption. Enable encryption at rest and RBAC, or use an external-secrets operator that syncs from a real vault. - Mount as files rather than env vars for anything sensitive: environment variables leak into crash dumps,
/proc, and child processes more readily.
That last point is a genuine distinction most people don’t make, and it’s worth having.
Feature flags are config too
Runtime-changeable behaviour belongs with config thinking: flags in a store rather than in code, with a default that’s safe when the store is unreachable, and a cleanup policy so flags don’t accumulate into permanent dead branches.
Interview angle
- “How do you manage secrets?” — prefer workload identity so no secret exists; otherwise a secrets manager fetched at runtime with a bounded cache. Never in the repo, never baked into an image. Config comes from the environment, typed and validated at startup so a missing value fails on boot.
- “Why validate config at startup?” — a missing variable should crash the process where deployment tooling sees it, not surface at 3am on the first request that touches that code path.
- “What’s
SecretStrfor?” — its repr is masked, so logs, tracebacks and debugger output don’t leak the value. You have to call.get_secret_value()deliberately, which makes exposure a conscious act. - “A secret was committed to git. What do you do?” — rotate the credential immediately. It’s in the reflog, in every clone and in any fork, so history rewriting doesn’t make it safe. Then add
gitleaksin pre-commit and CI plus push protection. - “How do you rotate a secret without downtime?” — accept both old and new during an overlap window, use a short cache TTL so the new value is picked up in minutes, and re-fetch once on an auth failure to cover the revocation window.
- “Kubernetes Secrets are encrypted, right?” — no, base64-encoded. Enable encryption at rest, restrict with RBAC, or sync from a real vault via an external-secrets operator. And mount sensitive values as files rather than environment variables, which leak into crash dumps and child processes.