Explicit is better than implicit

The Zen of Python says so. Your secrets should follow the same philosophy.

python
>>> import this
Explicit is better than implicit.
Errors should never pass silently.
Unless explicitly silenced.
>>> # Your secrets agree

The Pattern You've Written

Every Python tutorial ever

import os

# "Just use os.environ.get with a default"
database_url = os.environ.get(
    'DATABASE_URL',
    'sqlite:///local.db'
)

# Runs fine locally
# Runs fine in CI
# Runs fine in production with...
#   wait, why is it using SQLite?

What production actually needs

import os
import sys

# Fail fast. Fail loud.
database_url = os.environ.get('DATABASE_URL')
if not database_url:
    print("DATABASE_URL required", file=sys.stderr)
    print("Run: envv run -- python app.py")
    sys.exit(1)

# Now you know it's there

Errors should never pass silently.

Missing secrets aren't silent errors. They're landmines.

Flask

The micro-framework that trusts you to handle config. So handle it.

app.py

from flask import Flask
import os
import sys

def require_env(key: str) -> str:
    """Get an environment variable or die trying."""
    value = os.environ.get(key)
    if not value:
        print(f"Missing required: {key}", file=sys.stderr)
        sys.exit(1)
    return value

app = Flask(__name__)
app.secret_key = require_env('SECRET_KEY')

DATABASE_URL = require_env('DATABASE_URL')
STRIPE_KEY = require_env('STRIPE_API_KEY')

@app.route('/health')
def health():
    return {'status': 'ok', 'config': 'valid'}

# If you got here, everything exists
run
envv run -- flask run
* Running on http://127.0.0.1:5000

FastAPI

Pydantic for request validation. Why not config validation?

config.py — Pydantic Settings

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    stripe_api_key: str
    secret_key: str
    debug: bool = False

    class Config:
        # envv injects these into the environment
        env_file = None  # We don't need .env files

# Validates on import. Crashes if anything missing.
settings = Settings()

# Type-safe access everywhere:
# settings.database_url  # str, guaranteed
# settings.debug         # bool, with default

main.py

from fastapi import FastAPI
from config import settings

app = FastAPI()

@app.get("/")
def root():
    return {
        "status": "ok",
        "debug": settings.debug,
        # Mask the actual value
        "database": settings.database_url.split('@')[-1]
    }
run
pip install pydantic-settings
envv run -- uvicorn main:app
Uvicorn running on http://127.0.0.1:8000

Scripts

Not everything is a web app. One-off scripts need secrets too.

migrate.py

#!/usr/bin/env python3
"""Database migration script."""
import os
import sys

DATABASE_URL = os.environ.get('DATABASE_URL')
if not DATABASE_URL:
    print("Usage: envv run -- python migrate.py")
    sys.exit(1)

def main():
    print(f"Migrating: {DATABASE_URL.split('@')[-1]}")
    # ... migration logic

if __name__ == '__main__':
    main()
run script
envv run -- python migrate.py
Migrating: localhost:5432/myapp

Traps Pythonistas Fall Into

python-dotenv everywhere

from dotenv import load_dotenv
load_dotenv()  # Reads .env into os.environ

# Now you have a .env file
# That needs to be distributed
# And is probably in git history
# And is definitely plaintext

envv instead

# No load_dotenv. No .env file.
# envv injects secrets before Python runs

envv run -- python app.py

# os.environ already populated
# .env.encrypted is ciphertext
# Safe to commit

Debugging with print

# Late-night debugging
print(f"Config: {os.environ}")

# Oops, that went to the logs
# Which went to CloudWatch
# Which your whole team can read
# Including the Stripe key

Mask before logging

def mask(s: str) -> str:
    if len(s) <= 8:
        return "***"
    return f"{s[:4]}...{s[-4:]}"

print(f"API Key: {mask(api_key)}")
# Output: API Key: sk_l...xxxx

Setup

one time
# Install
curl -fsSL https://getenvv.com/envv | sh
# Encrypt your existing .env (if you have one)
envv push .env --env dev
# Delete the plaintext
rm .env
# Update .gitignore
echo ".env" >> .gitignore
# Commit the encrypted version
git add .env.encrypted .gitignore

Simple is better than complex.

One command. One pattern. All your Python projects.

curl -fsSL https://getenvv.com/envv | sh