Production secrets — database passwords, JWT secret keys, API keys — must never appear in source code, Docker images, or CI logs. A single leaked .env file committed to a public repository can expose every credential in your stack. The disciplines are: store secrets only in secure vaults (GitHub Secrets for CI, environment variables injected at runtime for Docker), validate required secrets at startup so a missing variable causes an immediate crash rather than a silent misconfiguration, and rotate compromised secrets immediately.
FastAPI Pydantic Settings with Strict Validation
# app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import field_validator, SecretStr, PostgresDsn
from typing import Literal
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file = ".env",
env_file_encoding = "utf-8",
case_sensitive = False,
extra = "ignore", # ignore unknown env vars
)
# ── Application ───────────────────────────────────────────────────────────
environment: Literal["development", "test", "production"] = "development"
debug: bool = False
# ── Database — required, no default ───────────────────────────────────────
database_url: PostgresDsn # e.g. postgresql://user:pass@host/db
db_pool_size: int = 10
db_max_overflow: int = 20
# ── Security — SecretStr hides value in repr and logs ─────────────────────
secret_key: SecretStr # required — used for JWT signing
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 7
# ── CORS ──────────────────────────────────────────────────────────────────
cors_origins: str = "" # comma-separated list
# ── Redis ─────────────────────────────────────────────────────────────────
redis_url: str = "redis://localhost:6379"
# ── Storage ───────────────────────────────────────────────────────────────
upload_dir: str = "/tmp/uploads"
max_upload_mb: int = 10
@field_validator("secret_key")
@classmethod
def secret_key_must_be_strong(cls, v: SecretStr) -> SecretStr:
if len(v.get_secret_value()) < 32:
raise ValueError("SECRET_KEY must be at least 32 characters")
return v
@property
def cors_origins_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@property
def secret_key_value(self) -> str:
return self.secret_key.get_secret_value()
# Module-level singleton — validated once on import
settings = Settings()
SecretStr type wraps a string value so it is never accidentally logged or printed. str(settings.secret_key) returns "**********", not the actual value. Access the real value only when needed: settings.secret_key.get_secret_value(). This prevents the most common accidental secret exposure: a developer adds a debug log statement that prints the settings object, and the JWT secret key appears in the application logs.python -c "import secrets; print(secrets.token_urlsafe(64))". A 64-character URL-safe base64 string provides 384 bits of entropy — far beyond any brute-force attack. Rotate the secret key by generating a new one and updating it in production. Note that rotating the secret key immediately invalidates all existing JWT tokens — all users will be logged out. Plan key rotation during a maintenance window or implement key versioning with multiple valid keys during transition.settings or configuration objects at startup unless you have explicitly audited every field for sensitive data. A common pattern of logger.info(f"Starting with config: {settings}") can expose database credentials, secret keys, and API tokens in your application logs, which may be stored in log aggregation services with broader access than the production environment itself. Log only the non-sensitive fields: logger.info(f"Environment: {settings.environment}, Debug: {settings.debug}").Docker Secrets vs Environment Variables
| Method | Security | Complexity | Use For |
|---|---|---|---|
| Environment variables | Medium (visible in docker inspect) |
Low | Most secrets for single-server deployments |
| Docker Secrets (Swarm) | High (mounted as file, not env var) | Medium | Multi-node Docker Swarm deployments |
| AWS SSM Parameter Store | Very high (encrypted, IAM-controlled) | High | AWS deployments, team environments |
| HashiCorp Vault | Very high (dynamic credentials) | Very high | Enterprise, dynamic short-lived secrets |
Generating Production Secrets
# Generate a strong secret key
python -c "import secrets; print(secrets.token_urlsafe(64))"
# Generate a strong PostgreSQL password
python -c "import secrets; print(secrets.token_urlsafe(32))"
# Create .env.production (NEVER commit this file)
cat > .env.production << EOF
ENVIRONMENT=production
DEBUG=false
DATABASE_URL=postgresql://blog_user:${POSTGRES_PASSWORD}@db/blog_prod
SECRET_KEY=${SECRET_KEY}
CORS_ORIGINS=https://blog.example.com
REDIS_URL=redis://redis:6379
EOF
# Ensure .env.production is in .gitignore
echo ".env.production" >> .gitignore
Common Mistakes
Mistake 1 — Committing .env files to git
❌ Wrong — .env.production with real passwords in version control:
Even in private repositories, team member access, CI service access, and third-party integrations all become attack vectors. Use git secret or store secrets in a vault.
✅ Correct — commit only .env.example with placeholder values; the real values live outside git.
Mistake 2 — No startup validation (silently misconfigured)
❌ Wrong — missing SECRET_KEY causes JWT signing to fail on first token issuance, not at startup.
✅ Correct — Pydantic Settings validates required fields at import time; the application fails immediately with a clear error if a required variable is missing.