Two years ago, a colleague accidentally pushed an .env file to a public GitHub repository. It contained the production database URL, an AWS access key, and a Stripe secret key. Within 6 hours, someone had spun up 14 EC2 instances for cryptocurrency mining. The AWS bill hit $2,300 before we noticed.
Configuration management — especially secrets — is one of those things that seems simple until you get it wrong. Here’s everything I’ve learned about doing it right.
The 12-Factor App Approach
The 12-Factor methodology defines a clear rule: store configuration in environment variables, not in code. Configuration is anything that varies between environments (development, staging, production):
- Database connection strings
- API keys and secrets
- Feature flags
- Service URLs
- Port numbers
// Bad: hardcoded config
const db = new Database('postgres://admin:pass@prod-db:5432/myapp');
// Good: environment variable
const db = new Database(process.env.DATABASE_URL);
Environment variables separate config from code. Your codebase can be open-source without exposing any credentials.
.env Files: Local Development
Basic Structure
# .env
DATABASE_URL=postgres://localhost:5432/myapp_dev
API_KEY=sk_test_abc123
REDIS_URL=redis://localhost:6379
DEBUG=true
PORT=3000
Loading .env Files
Node.js (dotenv):
require('dotenv').config();
// or in ES modules:
import 'dotenv/config';
console.log(process.env.DATABASE_URL);
Python (python-dotenv):
from dotenv import load_dotenv
import os
load_dotenv()
db_url = os.getenv('DATABASE_URL')
.env File Hierarchy
Most frameworks support multiple .env files with a priority order:
.env.local ← highest priority (git-ignored)
.env.development ← environment-specific
.env ← shared defaults (can be committed)
What goes where:
.env— non-sensitive defaults shared across the team (PORT=3000,LOG_LEVEL=info).env.local— personal overrides and secrets (always gitignored).env.production— production-specific non-secret values
Never Store Secrets in Code
The .gitignore Rule
# Always gitignore these
.env.local
.env.*.local
.env.production
.env.staging
# Keep shared defaults
# !.env (commit this if it only contains non-sensitive values)
Scanning for Leaked Secrets
Even with .gitignore, secrets slip through. Tools like git-secrets, truffleHog, and gitleaks scan your repository for accidentally committed credentials:
# Install gitleaks
brew install gitleaks
# Scan your repo
gitleaks detect --source . --verbose
Add this to your CI pipeline — it takes seconds and catches mistakes before they reach your remote repository.
What to Do If You Leak a Secret
- Revoke the key immediately — don’t just remove it from code
- Rotate all exposed credentials — generate new keys
- Scrub git history —
git filter-branchor BFG Repo Cleaner - Audit access logs — check if the key was used maliciously
- Add .gitignore rules — prevent recurrence
The key is already in your git history even if you delete the file in a new commit. Step 3 is essential.
Environment Variable Patterns
Validation at Startup
Don’t wait until a function needs a variable to discover it’s missing. Validate everything at application startup:
const required = [
'DATABASE_URL',
'API_KEY',
'JWT_SECRET',
'REDIS_URL',
];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error(`Missing required env vars: ${missing.join(', ')}`);
process.exit(1);
}
With Zod (my preferred approach):
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(10),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
DEBUG: z.coerce.boolean().default(false),
});
export const env = envSchema.parse(process.env);
// Now env.PORT is a number, env.DEBUG is a boolean
// Missing or invalid values throw descriptive errors at startup
Type Conversion
Environment variables are always strings. Forgetting this causes subtle bugs:
// Bug: this is the STRING "3000", not the number 3000
const port = process.env.PORT;
console.log(port + 1); // "30001" not 3001
// Fix: explicit conversion
const port = parseInt(process.env.PORT, 10) || 3000;
// Bug: this is the STRING "false", which is truthy!
if (process.env.DEBUG) {
enableDebugMode(); // Always runs!
}
// Fix: explicit boolean check
const debug = process.env.DEBUG === 'true';
Encoding secrets? If you need to store binary data or certificates in environment variables, Base64 encoding is the standard approach. Use our Base64 Encoder to encode values, and decode them in your application at startup.
Secrets Management in Production
Platform-Native Secret Stores
For production, don’t use .env files. Use your platform’s secret management:
| Platform | Secret Store |
|---|---|
| AWS | Secrets Manager, SSM Parameter Store |
| Google Cloud | Secret Manager |
| Azure | Key Vault |
| Kubernetes | Secrets (with encryption at rest) |
| Vercel | Environment Variables (encrypted) |
| Cloudflare | Workers Secrets |
| Docker | Docker Secrets (Swarm mode) |
Kubernetes Secrets
# secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
data:
DATABASE_URL: cG9zdGdyZXM6Ly9hZG1pbjpwYXNzQGRiOjU0MzIvYXBw # base64
API_KEY: c2tfdGVzdF9hYmMxMjM= # base64
# deployment.yaml
spec:
containers:
- name: app
envFrom:
- secretRef:
name: app-secrets
Note that Kubernetes secrets are Base64-encoded, not encrypted. Enable encryption at rest in your cluster configuration.
Config Files: YAML vs JSON vs TOML
For non-secret configuration, structured config files are often better than environment variables:
| Format | Best For | Comments | Drawback |
|---|---|---|---|
| YAML | Kubernetes, Docker, CI/CD | Yes | Indentation-sensitive |
| JSON | APIs, package.json, tsconfig | No | Verbose, no comments |
| TOML | Rust (Cargo.toml), Python (pyproject.toml) | Yes | Less tooling support |
| .env | Local development secrets | Yes (some parsers) | Flat key-value only |
For converting between formats, our YAML to JSON Converter handles bidirectional conversion when you need to switch between config formats.
Common Mistakes
Mistake 1: Different Variable Names Across Environments
# Development
DB_HOST=localhost
# Production
DATABASE_HOST=prod-db.example.com # Different name!
Use identical variable names across all environments. Only the values should change.
Mistake 2: Storing Secrets in Docker Images
# NEVER do this — secret is baked into the image layer
ENV API_KEY=sk_live_abc123
# Instead, pass at runtime
# docker run -e API_KEY=sk_live_abc123 myapp
Docker image layers are permanent. Even if you delete the ENV line in a later layer, the secret exists in previous layers.
Mistake 3: Logging Environment Variables
// Accidentally logs all secrets
console.log('Config:', process.env);
// Safe: log only non-sensitive values
console.log('Config:', {
port: process.env.PORT,
env: process.env.NODE_ENV,
dbHost: process.env.DB_HOST,
// Never log: API_KEY, JWT_SECRET, DATABASE_URL
});
Mistake 4: Using Production Secrets in Development
Your local .env should use test/development credentials, never production. If your dev database URL points to production, one bad migration wipes real user data.
Quick Reference
| Practice | Do | Don’t |
|---|---|---|
| Secrets | Platform secret store | Hardcode in source |
| Local dev | .env.local (gitignored) | Share real credentials |
| Validation | Validate at startup | Fail silently at runtime |
| Types | Explicit conversion | Trust string values |
| Docker | Runtime --env or --env-file | ENV in Dockerfile |
| Git | .gitignore + secret scanning | Commit .env.local |
Further Reading
- Encoding & Hashing Guide
- Password Security: What Every Developer Should Know
- JSON vs YAML: Config File Mistakes
Managing configuration? Use our Base64 Encoder for encoding secrets, Hash Generator for integrity checks, and YAML to JSON Converter for config format conversion.