Files
2025-11-29 18:46:15 +08:00

14 KiB

Environment Variable Best Practices

Comprehensive guide to managing environment variables in Next.js applications.

Table of Contents

  1. Security Best Practices
  2. Naming Conventions
  3. Scoping Rules
  4. Common Patterns
  5. Environment-Specific Configuration
  6. Secret Rotation
  7. Testing Strategies
  8. 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:

  1. Generate new secret
  2. Deploy with NEW_JWT_SECRET environment variable
  3. Update code to validate with both old and new secrets
  4. After grace period (e.g., 7 days), remove old secret
  5. Rename NEW_JWT_SECRET to JWT_SECRET

For database passwords:

  1. Create new database user/password
  2. Deploy with new DATABASE_URL (no downtime)
  3. Monitor for 24 hours
  4. Delete old database user
  5. Update password manager/secrets storage

For API keys (if service supports multiple keys):

  1. Generate new API key (keep old active)
  2. Deploy with new key
  3. Monitor for 24 hours
  4. 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_ENV is set to production
  • URLs are production URLs (no localhost)
  • .env.production is 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:

  1. Rotate all exposed secrets immediately
  2. Use git filter-branch or BFG Repo-Cleaner to remove from history
  3. Force push (if safe to do so)
  4. 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;

Resources