.env File Best Practices: Managing Environment Variables Like a Pro
Environment variables are how modern applications handle configuration — database URLs, API keys, feature flags, and service credentials. The .env file pattern, popularized by the Twelve-Factor App methodology, keeps these values out of your codebase. But .env files come with their own set of problems: secret sprawl, configuration drift between environments, missing variables that crash production, and developers accidentally committing credentials.
This guide covers everything from .env basics to battle-tested practices for teams.
.env File Syntax
# Database
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
DATABASE_POOL_SIZE=10
# API Keys
STRIPE_SECRET_KEY=sk_test_4eC39HqLyjWDarjtT1zdp7dc
STRIPE_WEBHOOK_SECRET=whsec_test_secret
# Application
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
# Feature Flags
ENABLE_NEW_DASHBOARD=true
ENABLE_BETA_API=false
# Multi-line values (quotes required)
RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA0Z3VS5JJcds...
-----END RSA PRIVATE KEY-----"
# Values with spaces (quotes required)
APP_NAME="My Cool App"
# Empty values
SENTRY_DSN=
Rules: one variable per line, KEY=VALUE format, no spaces around =, # for comments. Quotes are optional for simple values but required for multi-line values or values containing spaces.
The .env.example Pattern
Every project should have a .env.example file checked into version control:
# .env.example — committed to git
# Copy to .env and fill in real values
# Database (required)
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# Stripe (required for payments)
STRIPE_SECRET_KEY=sk_test_YOUR_KEY_HERE
STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET_HERE
# Application
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
# Optional: Sentry error tracking
SENTRY_DSN=
This file serves three purposes:
- Documentation — new developers know exactly which variables are needed
- Template —
cp .env.example .envgives you a starting point - Drift detection — compare
.envagainst.env.exampleto find missing or extra variables
Never Commit .env Files
Your .gitignore should always include:
# .gitignore
.env
.env.local
.env.*.local
.env.production
.env.staging
# DO commit these:
# .env.example
# .env.test (if it contains no real secrets)
If you've already committed a .env file, removing it from git isn't enough — it's in the history forever. You need to:
- Remove the file from tracking:
git rm --cached .env - Add to .gitignore
- Commit and push
- Rotate every credential that was in the file — assume they're compromised
- Optionally, rewrite git history with
git filter-branchor BFG Repo-Cleaner
Environment-Specific Files
Most frameworks support a hierarchy of .env files:
.env # Default values (committed or not — team preference)
.env.local # Local overrides (never committed)
.env.development # Development-specific
.env.staging # Staging-specific
.env.production # Production-specific
.env.test # Test environment
Loading order typically follows a priority chain: .env.local overrides .env.{'{'}environment{'}'} which overrides .env. Check your framework's documentation — Next.js, Vite, Rails, and Laravel each handle this slightly differently.
Detecting Missing Variables
The most common .env bug: a variable exists in production but is missing from a developer's local .env, or vice versa. The app starts fine but crashes when it tries to use the missing variable.
Validate at Startup
// validate-env.js — run at application startup
const required = [
'DATABASE_URL',
'STRIPE_SECRET_KEY',
'JWT_SECRET',
];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error('Missing required environment variables:');
missing.forEach(key => console.error(' - ' + key));
process.exit(1);
}
Use a Schema Library
// With zod (recommended)
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'staging', 'production']),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export const env = envSchema.parse(process.env);
Schema validation catches not just missing variables but wrong types, invalid formats, and missing defaults. Libraries like zod, envalid, and @t3-oss/env-core all work well for this.
Diff Your .env Files
Use our .env Diff & Merge tool to compare your .env against .env.example — it highlights missing keys, extra keys, and changed values instantly.
Secrets Management for Teams
For teams, sharing .env files via Slack, email, or shared drives is a security antipattern. Better approaches:
| Approach | Best For | Cost |
|---|---|---|
| 1Password / Bitwarden shared vaults | Small teams | $4-8/user/mo |
| Doppler / Infisical | Dedicated secrets management | Free tier available |
| AWS Secrets Manager | AWS-heavy teams | $0.40/secret/mo |
| HashiCorp Vault | Enterprise, multi-cloud | Free (self-hosted) |
| GitHub Actions Secrets | CI/CD only | Free |
| git-crypt | Encrypted files in repo | Free |
The goal: no developer should need to manually copy-paste secrets.
CI/CD Environment Variables
In CI/CD pipelines, environment variables are set differently than local development:
# GitHub Actions — use secrets context
# DATABASE_URL: secrets.DATABASE_URL
# Docker
docker run -e DATABASE_URL=postgres://... myapp
# Docker Compose
services:
app:
env_file:
- .env.production
# Kubernetes (from Secret)
envFrom:
- secretRef:
name: myapp-secrets
Never bake secrets into Docker images. Use runtime environment variables or mounted secrets.
Common .env Mistakes
- Committing .env to git — always add to .gitignore before your first commit
- Using production credentials locally — use separate credentials per environment
- Not rotating secrets — if a secret might be exposed, rotate it immediately
- Hardcoding fallback values —
process.env.DB_URL || "postgres://..."hides missing config - Inconsistent naming — pick a convention (
SCREAMING_SNAKE_CASE) and stick to it - No .env.example — new team members shouldn't have to guess which variables are needed
- Storing non-secrets in .env — public config can go in a regular config file
- Sharing .env via Slack/email — use a secrets manager instead
Quick Reference: .env Loading Libraries
| Language | Library | Usage |
|---|---|---|
| Node.js | dotenv | require('dotenv').config() |
| Node.js (built-in) | Node 20.6+ | node --env-file=.env app.js |
| Python | python-dotenv | load_dotenv() |
| Ruby | dotenv gem | Dotenv.load |
| Go | godotenv | godotenv.Load() |
| PHP | vlucas/phpdotenv | Dotenv::createImmutable(__DIR__)->load() |
| Rust | dotenvy | dotenvy::dotenv().ok() |
Compare your .env files side by side with our .env Diff & Merge tool — find missing keys, detect changes, and generate merged configs instantly.