Environment Variables: Configuration Done Right
Hardcoded API keys, database URLs, and feature flags are the #1 cause of security incidents in small teams. Environment variables solve this by separating configuration from code. Your app reads DATABASE_URL instead of containing postgres://admin:password123@prod-db:5432.
Why Environment Variables
The 12-factor app methodology says it best: store config in the environment. Benefits:
- Security: Secrets aren't in your git history
- Flexibility: Same code runs in dev, staging, and prod with different configs
- Simplicity: No complex config file formats to parse
The .env File
Most frameworks support a .env file at the project root:
# .env — NEVER commit this file
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secret-key-here
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_API_URL=https://api.example.com
NODE_ENV=development
PORT=3000Load it in your app:
// Node.js — dotenv (most common)
import 'dotenv/config';
// or
require('dotenv').config();
console.log(process.env.DATABASE_URL);
// "postgres://user:pass@localhost:5432/mydb"# Python — python-dotenv
from dotenv import load_dotenv
import os
load_dotenv()
db_url = os.getenv('DATABASE_URL')Naming Conventions
| Convention | Example | Notes |
|---|---|---|
| SCREAMING_SNAKE_CASE | DATABASE_URL | Universal standard for env vars |
| Prefix with service | STRIPE_SECRET_KEY | Groups related vars |
Prefix with NEXT_PUBLIC_ | NEXT_PUBLIC_API_URL | Next.js: exposed to browser |
Prefix with VITE_ | VITE_API_URL | Vite: exposed to browser |
Prefix with REACT_APP_ | REACT_APP_API_URL | CRA: exposed to browser |
Critical: Only variables with the framework-specific prefix are exposed to the browser. Never put secrets in NEXT_PUBLIC_ or VITE_ variables — they'll be in your JavaScript bundle.
Environment File Hierarchy
Most frameworks load env files in this priority (later overrides earlier):
.env # Default values, committed to repo (no secrets!)
.env.local # Local overrides, gitignored
.env.development # Development-specific
.env.production # Production-specific
.env.test # Test-specificSecrets in CI/CD
Never put real secrets in .env files that get deployed. Use your CI/CD platform's secrets management:
# GitHub Actions
name: Deploy
on: push
jobs:
deploy:
runs-on: ubuntu-latest
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
steps:
- run: npm run deploy# Docker Compose
services:
app:
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
# Or from a file:
env_file:
- .env.productionValidation at Startup
Don't wait for a runtime crash to discover a missing env var. Validate at startup:
// TypeScript 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', 'production', 'test']),
});
export const env = envSchema.parse(process.env);
// Throws immediately if any variable is missing or invalid# Python with pydantic
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
jwt_secret: str
port: int = 3000
debug: bool = False
class Config:
env_file = '.env'
settings = Settings() # Validates on instantiationCommon Mistakes
- Committing .env to git. Add it to
.gitignorebefore your first commit. Check withgit log -- .env. - Using env vars for structured data.
ALLOWED_ORIGINS=http://localhost:3000,https://myapp.comworks, but for complex config, use a config file that reads env vars. - Different .env formats across tools. Some tools need quotes around values, some don't.
KEY="value"andKEY=valuebehave differently in shell vs dotenv. - Forgetting to restart. Env vars are read at process start. Changing
.envrequires restarting the server.
Manage your env files: Env Formatter — sort, deduplicate, and validate your .env files.