.env Best Practices
Reference: dotenv.space — the complete .env guide for every stack.
Core Rules (Never Break These)
- Never commit
.env— add it to.gitignorebefore the first commit, not after. - Always commit
.env.example— strip real values, keep all variable names and comments. - Rotate immediately if exposed — deleting the file is not enough. Git history retains it. Use
git filter-repoor BFG Repo Cleaner AND rotate every key. - Bots scan GitHub continuously. Exposed keys are found within minutes of a push.
- Every env var is a string. Parse types explicitly in application code.
The File Hierarchy
| File | Commit? | Purpose |
|---|---|---|
.env | ❌ Never | Local real values — your actual secrets |
.env.example | ✅ Always | Template with placeholder values — the team contract |
.env.local | ❌ Never | Machine-specific overrides (Next.js / Vite convention) |
.env.development | ✅ If no real secrets | Non-secret dev defaults shared by the team |
.env.production | ❌ Never | Managed by infra/CI — never lives in the repo |
.env.test | ✅ If mock keys only | CI test values using fake/mock credentials |
The .gitignore Template
# .env files — never commit
.env
.env.local
.env.production
.env.staging
.env.*.local
# DO commit this — do not add to .gitignore:
# .env.example
The .env.example Template Pattern
# Application
APP_NAME=MyApp
SECRET_KEY=CHANGE_ME_generate_with_openssl_rand_hex_32
DEBUG=False
PORT=8000
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
REDIS_URL=redis://localhost:6379/0
# Third-party APIs — get keys from the provider dashboard
STRIPE_SECRET_KEY=sk_test_YOUR_KEY_HERE
OPENAI_API_KEY=sk-proj-YOUR_KEY_HERE
AWS_ACCESS_KEY_ID=YOUR_AWS_KEY_HERE
AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_HERE
# Feature flags
ENABLE_FEATURE_X=False
Framework-Specific Rules
Next.js
NEXT_PUBLIC_prefix → inlined into browser bundle at build time (public, visible in DevTools)- No prefix → server-only (never reaches the browser)
- Must exist at
next buildtime, not just runtime. Pass to CI build step explicitly.
Vite
VITE_prefix → exposed to browser (import.meta.env.VITE_X)- No prefix → build-scripts only, not bundled
Django
- Use
django-environorpython-dotenv; never put real credentials insettings.py "False"is a truthy string — always parse booleans explicitly
Rust
- Use
dotenvyfor loading,config+secrecy::SecretStringfor type-safe production config - Always
.trim()before.parse()— trailing whitespace silently breaks type parsing
Docker / Docker Compose
- Use
env_file:in compose, notenvironment:with inline values - Never
ENV SECRET_KEY=...in Dockerfile — it's visible in every layer anddocker inspect - Docker Compose resolves
env_filepaths relative to the Compose file location, not the CWD
The Boolean Trap (Extremely Common Bug)
# ❌ WRONG — the string "False" is truthy in Python
if os.getenv("DEBUG"):
...
# ✅ CORRECT
DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes")
// ❌ WRONG
let debug = std::env::var("DEBUG").unwrap_or("false".to_string());
// ✅ CORRECT — trim and compare
let debug = std::env::var("DEBUG")
.unwrap_or_default()
.trim()
.to_lowercase() == "true";
Common Bugs Quick Reference
For detailed debugging patterns covering 10 frequent .env bugs (undefined variables, NEXT_PUBLIC in production, Docker path errors, boolean trap, Sentry leaks, Vite prefix, GitHub Actions masking, ALLOWED_HOSTS, Rust parse panics, silent teammate breakage), see references/debugging.md.
CI/CD — No .env Files Exist Here
In CI/CD pipelines, variables are injected from the platform's secret store. .env files should never be present.
# GitHub Actions — correct pattern
jobs:
deploy:
runs-on: ubuntu-latest
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
Enable GitHub Push Protection now: Settings → Code security → Push protection. Blocks 100+ secret patterns automatically. Free, takes 30 seconds. Do it before you forget.
Startup Validation — Fail Fast
Always validate required variables at startup, not at runtime when the missing variable is first used.
# Python — validate at import time
def validate_env():
required = ["SECRET_KEY", "DATABASE_URL", "STRIPE_SECRET_KEY"]
missing = [k for k in required if not os.getenv(k)]
if missing:
raise RuntimeError(f"Missing required env vars: {missing}")
validate_env()
// TypeScript / Node.js
const required = ["DATABASE_URL", "SECRET_KEY"] as const;
for (const key of required) {
if (!process.env[key]) throw new Error(`Missing required env var: ${key}`);
}
Secret Managers — When to Graduate from .env
.env is for local development. For production, use a secrets manager.
| Tool | Best For | Cost |
|---|---|---|
| AWS Secrets Manager | AWS workloads | ~$0.40/secret/mo |
| HashiCorp Vault | Multi-cloud, enterprise | Free self-hosted |
| Doppler | Multi-env teams, great DX | Free tier / $6+/mo |
| Infisical | Open-source Doppler alt | Free self-hosted |
| GCP Secret Manager | GCP workloads | $0.06/10k accesses |
| Azure Key Vault | Azure workloads | $0.03/10k ops |
Using evnx for Automated Validation
If evnx is available, use it for automated validation and secret scanning:
# Install evnx (choose your preferred method)
cargo install evnx # Rust/Cargo
npm install -g @evnx/cli # Node.js/npm
pip install evnx # Python/pip
# Run validation and secret scanning
evnx validate --strict
evnx scan
evnx diff # Compare .env with .env.example
See the evnx-cli skill for the complete command reference and CI/CD integration patterns.
Further Reference
- references/format-spec.md —
.envformat grammar and edge cases - references/security-checklist.md — full pre-deploy checklist
- references/debugging.md — 10 common bugs with fixes
- dotenv.space — complete multi-stack reference