14 KiB
Environment Variable Best Practices
Comprehensive guide to managing environment variables in Next.js applications.
Table of Contents
- Security Best Practices
- Naming Conventions
- Scoping Rules
- Common Patterns
- Environment-Specific Configuration
- Secret Rotation
- Testing Strategies
- Deployment Checklist
Security Best Practices
1. Never Commit Secrets
Rule: Never commit .env.local or .env.production files
Setup .gitignore:
# Environment files with secrets
.env*.local
.env.production
.env.staging
# Only commit the template
!.env.example
2. Use Strong, Random Secrets
Requirements for secrets:
- Minimum 32 characters
- Cryptographically random
- High entropy (mix of characters, numbers, symbols)
Generate strong secrets:
# Node.js (recommended for JWT/session secrets)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# OpenSSL
openssl rand -hex 32
# Python
python -c "import secrets; print(secrets.token_hex(32))"
3. Scope Variables Correctly
Public variables (NEXT_PUBLIC_*):
- [OK] API endpoints
- [OK] Feature flags
- [OK] Client-side config
- API keys
- Database credentials
- Any secrets
Private variables (no prefix):
- [OK] Database URLs
- [OK] API secrets
- [OK] JWT/session secrets
- [OK] Third-party service credentials
4. Validate on Startup
Validate environment variables when app starts:
// lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NEXTAUTH_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'staging', 'production']),
NEXT_PUBLIC_API_URL: z.string().url(),
});
export const env = envSchema.parse(process.env);
Benefits:
- Fail fast on missing/invalid variables
- Type-safe access to env vars
- Clear documentation of required variables
5. Use Secret Management Tools
For production environments:
- Vercel: Use Environment Variables UI (automatic encryption)
- AWS: AWS Secrets Manager or Systems Manager Parameter Store
- GCP: Secret Manager
- Azure: Key Vault
- HashiCorp Vault: Enterprise secret management
- Doppler: Universal secrets manager
Benefits:
- Encryption at rest
- Access control and auditing
- Automatic rotation
- Version history
Naming Conventions
Standard Format
Use SCREAMING_SNAKE_CASE for all environment variables:
# [OK] CORRECT
DATABASE_URL="..."
JWT_SECRET="..."
NEXT_PUBLIC_API_URL="..."
# [X] WRONG
databaseUrl="..."
jwt-secret="..."
NextPublicApiUrl="..."
Prefixes
Next.js public variables:
NEXT_PUBLIC_API_URL="https://api.example.com"
NEXT_PUBLIC_APP_NAME="My App"
NEXT_PUBLIC_GOOGLE_MAPS_KEY="..." # Only if client-side usage
Service-specific prefixes (optional but recommended):
# Database
DATABASE_URL="..."
DATABASE_POOL_SIZE="..."
# Stripe
STRIPE_PUBLIC_KEY="..."
STRIPE_SECRET_KEY="..."
STRIPE_WEBHOOK_SECRET="..."
# Email
SMTP_HOST="..."
SMTP_PORT="..."
SMTP_USER="..."
SMTP_PASSWORD="..."
Avoid Redundancy
# [OK] GOOD - clear and concise
DATABASE_URL="..."
JWT_SECRET="..."
# [X] BAD - redundant
DATABASE_CONNECTION_URL="..." # "CONNECTION" is redundant with "URL"
JWT_SECRET_KEY="..." # "KEY" is redundant with "SECRET"
Scoping Rules
Public Variables (NEXT_PUBLIC_*)
When to use:
- Client-side API endpoints
- Feature flags that affect UI
- Public configuration (app name, version)
- Client-side analytics IDs
- Map API keys (if client-side only)
Example:
NEXT_PUBLIC_API_URL="https://api.example.com"
NEXT_PUBLIC_ANALYTICS_ID="G-XXXXXXXXXX"
NEXT_PUBLIC_FEATURE_NEW_UI="true"
Access in code:
// Available in both server and client components
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
Private Variables (no prefix)
When to use:
- Database credentials
- API secrets
- JWT/session secrets
- Third-party service credentials
- Server-side API keys
Example:
DATABASE_URL="postgresql://..."
JWT_SECRET="..."
STRIPE_SECRET_KEY="sk_live_..."
OPENAI_API_KEY="sk-..."
Access in code:
// Only available in server components and API routes
const dbUrl = process.env.DATABASE_URL; // Server-side only
Mixed Scenarios
Stripe example (public + private keys):
# Public key - client-side checkout
NEXT_PUBLIC_STRIPE_PUBLIC_KEY="pk_live_..."
# Secret key - server-side payments
STRIPE_SECRET_KEY="sk_live_..."
# Webhook secret - server-side webhooks
STRIPE_WEBHOOK_SECRET="whsec_..."
Map services example:
# If using client-side maps
NEXT_PUBLIC_GOOGLE_MAPS_KEY="..."
# If using server-side geocoding
GOOGLE_MAPS_SERVER_KEY="..."
Common Patterns
Database Configuration
# Standard database URL
DATABASE_URL="postgresql://user:password@host:5432/dbname"
# Optional connection pool settings
DATABASE_POOL_SIZE="10"
DATABASE_POOL_TIMEOUT="30000"
# Read replicas (optional)
DATABASE_READ_URL="postgresql://user:password@read-host:5432/dbname"
Authentication (NextAuth.js)
# Required for NextAuth
NEXTAUTH_URL="https://example.com"
NEXTAUTH_SECRET="..." # 32+ character random string
# OAuth providers (if using)
GOOGLE_CLIENT_ID="..."
GOOGLE_CLIENT_SECRET="..."
GITHUB_CLIENT_ID="..."
GITHUB_CLIENT_SECRET="..."
JWT Authentication (Custom)
# JWT secret
JWT_SECRET="..." # 32+ character random string
# Optional: token expiration
JWT_EXPIRES_IN="7d"
# Optional: refresh token secret
JWT_REFRESH_SECRET="..." # Different from JWT_SECRET
External APIs
# OpenAI
OPENAI_API_KEY="sk-..."
OPENAI_ORG_ID="org-..." # Optional
# Stripe
NEXT_PUBLIC_STRIPE_PUBLIC_KEY="pk_live_..."
STRIPE_SECRET_KEY="sk_live_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
# SendGrid
SENDGRID_API_KEY="SG...."
# AWS
AWS_ACCESS_KEY_ID="..."
AWS_SECRET_ACCESS_KEY="..."
AWS_REGION="us-east-1"
# Cloudinary
CLOUDINARY_URL="cloudinary://..."
Email Configuration
# SMTP
SMTP_HOST="smtp.gmail.com"
SMTP_PORT="587"
SMTP_USER="your-email@gmail.com"
SMTP_PASSWORD="app-specific-password"
# Email service API (alternative)
SENDGRID_API_KEY="SG...."
MAILGUN_API_KEY="..."
Monitoring and Logging
# Sentry
SENTRY_DSN="https://...@sentry.io/..."
SENTRY_AUTH_TOKEN="..." # For source maps upload
# LogRocket
LOGROCKET_APP_ID="..."
# Custom logging
LOG_LEVEL="info" # debug, info, warn, error
Environment-Specific Configuration
Development (.env.local)
# Permissive settings for development
NODE_ENV="development"
# Local database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp_dev"
# Development secrets (can be weak)
JWT_SECRET="dev-secret-change-in-production"
NEXTAUTH_SECRET="dev-nextauth-secret"
# Local URLs
NEXTAUTH_URL="http://localhost:3000"
NEXT_PUBLIC_API_URL="http://localhost:3000"
# Test API keys
STRIPE_SECRET_KEY="sk_test_..."
OPENAI_API_KEY="sk-test-..."
# Debug flags
DEBUG="true"
LOG_LEVEL="debug"
Staging (.env.staging)
# Staging environment
NODE_ENV="production" # Use production mode
# Staging database
DATABASE_URL="postgresql://user:password@staging-db:5432/myapp_staging"
# Production-like secrets (but different from prod)
JWT_SECRET="staging-secret-32-chars-minimum"
NEXTAUTH_SECRET="staging-nextauth-secret"
# Staging URLs
NEXTAUTH_URL="https://staging.example.com"
NEXT_PUBLIC_API_URL="https://staging.example.com"
# Test API keys (same as dev)
STRIPE_SECRET_KEY="sk_test_..."
OPENAI_API_KEY="sk-test-..."
# Staging-specific config
SENTRY_ENVIRONMENT="staging"
LOG_LEVEL="info"
Production (.env.production)
# Production environment
NODE_ENV="production"
# Production database
DATABASE_URL="postgresql://user:strong-password@prod-db:5432/myapp_prod"
# Strong, random secrets
JWT_SECRET="production-secret-use-crypto-random-32-chars-minimum"
NEXTAUTH_SECRET="production-nextauth-secret-also-32-chars-minimum"
# Production URLs
NEXTAUTH_URL="https://example.com"
NEXT_PUBLIC_API_URL="https://api.example.com"
# Production API keys
STRIPE_SECRET_KEY="sk_live_..."
OPENAI_API_KEY="sk-live-..."
# Production-specific config
SENTRY_ENVIRONMENT="production"
LOG_LEVEL="warn"
# Performance optimization
DATABASE_POOL_SIZE="20"
Secret Rotation
Why Rotate Secrets?
- Security best practice: Limit impact of potential leaks
- Compliance: Required by some standards (PCI-DSS, HIPAA)
- Team changes: Rotate after team member departures
- Suspected breach: Rotate immediately if compromise suspected
What to Rotate
Critical secrets (rotate regularly):
- JWT/session secrets (every 90 days)
- Database passwords (every 90 days)
- API keys for financial services (every 90 days)
Less critical (rotate on schedule or as needed):
- Third-party API keys (annually or on breach)
- OAuth secrets (annually or on breach)
Never rotate:
- Public API keys (if they're truly public)
- Client IDs (non-secret identifiers)
Rotation Process
For JWT/session secrets:
- Generate new secret
- Deploy with NEW_JWT_SECRET environment variable
- Update code to validate with both old and new secrets
- After grace period (e.g., 7 days), remove old secret
- Rename NEW_JWT_SECRET to JWT_SECRET
For database passwords:
- Create new database user/password
- Deploy with new DATABASE_URL (no downtime)
- Monitor for 24 hours
- Delete old database user
- Update password manager/secrets storage
For API keys (if service supports multiple keys):
- Generate new API key (keep old active)
- Deploy with new key
- Monitor for 24 hours
- Revoke old API key
Testing Strategies
Local Testing
Use .env.test for test environment:
# .env.test
NODE_ENV="test"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp_test"
JWT_SECRET="test-secret"
Load in tests:
// jest.config.js
module.exports = {
setupFiles: ['<rootDir>/jest.setup.js'],
};
// jest.setup.js
import { loadEnvConfig } from '@next/env';
loadEnvConfig(process.cwd());
Mock Environment Variables
// test/utils/env.ts
export function mockEnv(vars: Record<string, string>) {
const original = { ...process.env };
beforeAll(() => {
Object.assign(process.env, vars);
});
afterAll(() => {
process.env = original;
});
}
// In test file
import { mockEnv } from './utils/env';
describe('API', () => {
mockEnv({
JWT_SECRET: 'test-secret',
DATABASE_URL: 'postgresql://localhost/test',
});
// Your tests...
});
Validate Before Tests
// test/setup.ts
import { envSchema } from '@/lib/env';
try {
envSchema.parse(process.env);
} catch (error) {
console.error('Invalid test environment configuration:');
console.error(error);
process.exit(1);
}
Deployment Checklist
Before Deploying to Production
- All required variables are set in production environment
- Secrets are strong (32+ characters, random)
- No secrets in
NEXT_PUBLIC_*variables - Database URL points to production database
- API keys are production keys (not test keys)
NODE_ENVis set toproduction- URLs are production URLs (no localhost)
.env.productionis NOT committed to git- Secrets are stored in secure secret management system
- CI/CD has access to environment variables
- Monitoring/logging is configured (Sentry, etc.)
Validation Script
Run before deployment:
python scripts/validate_env.py --file .env.production
CI/CD Integration
GitHub Actions example:
- name: Validate environment
run: python scripts/validate_env.py --file .env.production
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
# ... other secrets from GitHub Secrets
Post-Deployment Verification
- Application starts without errors
- Database connection works
- Authentication works
- External API calls work
- No environment-related errors in logs
- Monitoring shows healthy status
Common Mistakes to Avoid
1. Committing Secrets to Git
# [X] NEVER DO THIS
git add .env.production
git commit -m "Add production config" # [ERROR] Secrets in git history
If you accidentally commit secrets:
- Rotate all exposed secrets immediately
- Use
git filter-branchor BFG Repo-Cleaner to remove from history - Force push (if safe to do so)
- Notify team and audit access
2. Exposing Secrets in Public Variables
# [X] WRONG
NEXT_PUBLIC_DATABASE_URL="postgresql://..." # [ERROR] Exposed to client
NEXT_PUBLIC_JWT_SECRET="..." # [ERROR] Exposed to client
3. Weak Secrets
# [X] WRONG
JWT_SECRET="secret" # [ERROR] Too weak
JWT_SECRET="password123" # [ERROR] Predictable
JWT_SECRET="myapp-secret" # [ERROR] Too short
4. Copy-Pasting Between Environments
# [X] WRONG - Same secrets in dev and prod
# .env.local
JWT_SECRET="abc123..."
# .env.production
JWT_SECRET="abc123..." # [ERROR] Should be different
5. Hardcoding in Code
// [X] WRONG
const apiKey = 'sk_live_hardcoded_key'; // [ERROR] Never hardcode
// [OK] CORRECT
const apiKey = process.env.STRIPE_SECRET_KEY;