Environment Variables and Secrets — Secure Configuration Management

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()
Note: Pydantic’s 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.
Tip: Generate a strong random secret key with: 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.
Warning: Never log 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.

🧠 Test Yourself

A developer accidentally runs logger.info(f"App settings: {settings}"). Which field is safe to expose and which is protected?