Environment Variables Guidelines
🎯 Why Environment Variables Matter
Environment variables (“env vars”) allow us to configure applications cleanly and securely without modifying the codebase.
They are critical for:
- Security (secrets never in code)
- Reproducibility (different environments with same code)
- Reliability (fail fast if critical config is missing)
- Scalability (infrastructure can modify settings without redeploy)
🗂️ Where Environment Variables Come From
| Environment | Source |
|---|---|
| Local Dev | .env loaded by Docker Compose or container |
| Docker / Kubernetes | Passed by orchestrator (docker-compose.yml, k8s ConfigMap/Secret) |
| Cloud (Prod) | Passed via Secret Manager, Env injection, or Platform (e.g., GCP Run, AWS ECS) |
✅ Applications never load .env files themselves.
✅ Environment must set variables before the app starts.
📄 How to Handle Environment Variables
1. Pass Vars via Infrastructure
Local Dev:
-
Store
.envfile in project root (make sure it’s added to.gitignore) -
Store a
.envrcfile in project root (and check it in), with the following contentdotenv -
Load automatically through:
-
Using
env_fileif usingdocker-compose.yml:services: backend: build: . env_file: - .env -
Using direnv for shell auto-loading, which automatically loads the content of
.envfile when youcdinto the project folder, and unloads them when youcdout of it.
-
Production:
- Inject secrets at deploy time (CI/CD pipeline, Secret Manager, GCP/AWS environment config)
2. Do NOT Load .env Files in Code
No dotenv.load_dotenv() calls in the application.
Instead, read environment variables directly:
import os
DATABASE_URL = os.environ["DATABASE_URL"]
3. Type Safety
Always cast env vars explicitly:
DEBUG_MODE = os.environ.get("DEBUG_MODE", "false").lower() == "true"
MAX_CONNECTIONS = int(os.environ.get("MAX_CONNECTIONS", "10"))
📦 Local Development Flow
Every project must include:
.env.example— Committed (template only, no real secrets).envrc— Committed with a single line:dotenv. This instructdirenvto load environment variables from.envin the same director.env— Ignored via.gitignore(created manually from example)- Always wrap values inside double quotes (even numbers and boolean). This avoids surprises, and provide compatibility across different environments.
Typical .env:
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/db"
MAX_CONNECTIONS="20"
# Redis
REDIS_URL="redis://localhost:6379"
# API Keys
OPENAI_API_KEY="sk-..."
# App Settings
DEBUG_MODE="true"
ENVIRONMENT="local"
🔐 Secrets Management Rules
- Secrets must never be hardcoded
.envmust be in.gitignore.env.examplemust be kept up-to-date- Cloud secrets (prod) must be stored in Secret Manager or similar
- Rotate secrets regularly
⚙️ Good Practices for Using Environment Variables
- Validate critical env vars at app startup (fail fast)
- Group related vars logically (DB vars, Redis vars, LLM vars)
- Document important variables in project README or Notion
- Avoid leaking secrets in logs or errors
🧵 Environment Variables and Typer CLI
[TODO: Review]
CLI tools that require env vars should expect them to be preloaded (via Docker, local .env, or deployment config).
You may optionally expose fallback flags via CLI, but env vars are the default:
import os
import typer
app = typer.Typer()
@app.command()
def evaluate(model: str = typer.Option(os.environ.get("DEFAULT_MODEL", "gpt-4"))):
...
📋 Checklist for Handling Env Vars
-
.envis ignored in.gitignore -
.env.exampleis committed and updated - App does not load
.envmanually - Critical vars validated at startup
- Type-safe parsing of env vars
- No secrets printed in logs
- Safe secrets management in cloud environments
🧠 Final Note: Treat Environment Like a Contract
The environment is a first-class citizen, just like code and data.
A correct environment setup guarantees that the same code works reliably across local, staging, and production—without hacks, without surprises.