How to properly manage .env files across development, staging, and production — secrets management, validation, and common pitfalls to avoid.
Environment variables are how modern applications handle configuration — keeping secrets out of source code and making apps portable across environments. But most developers use them incorrectly. Here's the complete guide.
The Twelve-Factor methodology says: store config in environment, not in code. Everything that varies between deployments (dev, staging, production) belongs in environment variables:
# Comments start with #
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
# Quoted values (required if value contains spaces or # chars)
APP_NAME="My Cool App"
DESCRIPTION='Has a # hash'
# Multiline values (some loaders support this)
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA PRIVATE KEY-----"
# Empty value
OPTIONAL_FEATURE=
# Inline comments (NOT supported by all loaders — avoid!)
PORT=3000 # This comment may break some loaders
| File | Purpose | In git? |
|---|---|---|
.env | Defaults for all environments | Sometimes |
.env.local | Local overrides, machine-specific | Never |
.env.development | Development-only values | Sometimes |
.env.production | Production defaults (no secrets!) | Sometimes |
.env.example | Template with fake values, for docs | Always |
.env.test | Test environment values | Sometimes |
Rule: Never commit .env.local or any file with real credentials. Always commit .env.example.
Crashing at startup with a clear error is better than mysterious runtime failures. Validate all required env vars when your app boots:
// env.ts — validate with Zod
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'staging', 'production']),
OPENAI_API_KEY: z.string().optional(),
});
export const env = envSchema.parse(process.env);
// Throws at startup if any required var is missing or invalid
Never store real secrets in .env files in production. Use proper secrets management:
| Provider | Tool |
|---|---|
| AWS | AWS Secrets Manager, SSM Parameter Store |
| GCP | Secret Manager |
| Azure | Azure Key Vault |
| Vercel | Vercel Environment Variables |
| Self-hosted | HashiCorp Vault, Doppler |
# Example: pull secrets from AWS Secrets Manager at startup
aws secretsmanager get-secret-value --secret-id prod/myapp/env
1. Committing .env to git — add .env* to .gitignore (but allow .env.example).
2. Hardcoding defaults in code — process.env.PORT || 3000 is fine for non-sensitive values, but document required vars in .env.example.
3. Using REACT_APP_ or NEXT_PUBLIC_ for secrets — these prefixes expose values to the browser bundle. Never use them for API keys or secrets.
4. Not rotating compromised secrets — if a secret is accidentally committed to git, it's compromised forever (git history). Rotate it immediately.
5. Using the same secret across environments — development and production should have different keys, secrets, and credentials.
Your .env.example should list every variable your app needs, with placeholder values and comments:
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# Authentication
JWT_SECRET=change-this-to-a-random-32-char-string
SESSION_SECRET=change-this-too
# External APIs (optional — AI features disabled if not set)
OPENAI_API_KEY=sk-...
# App
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Use the .env Parser tool to validate your .env files, find duplicate keys, and export them as JSON for debugging.