The Problem with Hardcoded Secrets
Every year, thousands of credentials get leaked on GitHub because someone hardcoded a database password, API key, or private key directly in source code. The fix sounds simple — "just use environment variables" — but the actual implementation across multiple environments and team members is where most projects go wrong.
This guide covers everything from basic .env files to production-grade secrets management.
The Twelve-Factor App: Config Principle
The Twelve-Factor methodology says: "Store config in the environment." Anything that varies between deployments (dev/staging/prod) belongs in environment variables:
- Database connection strings
- API keys and tokens
- OAuth client secrets
- Third-party service credentials
- Feature flags
- Port numbers and hostnames
Code that doesn't vary between environments (business logic, algorithms) belongs in the repository.
.env Files: Development Basics
# .env (development only — NEVER commit to git)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
API_KEY=dev-key-not-real
JWT_SECRET=development-secret-change-in-production
STRIPE_SECRET_KEY=sk_test_...
# .env.example (ALWAYS commit — shows what variables are needed)
NODE_ENV=
PORT=3000
DATABASE_URL=
REDIS_URL=
API_KEY=
JWT_SECRET=
STRIPE_SECRET_KEY=
# .gitignore — absolutely required
.env
.env.local
.env.*.local
.env.production
# But NOT .env.example
Loading .env in Node.js
// Using dotenv (most common)
import 'dotenv/config'; // ES modules — loads .env automatically
// Or manually:
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' }); // Specify custom path
// Access variables
const dbUrl = process.env.DATABASE_URL;
const port = parseInt(process.env.PORT ?? '3000', 10);
Validating Environment Variables (Critical!)
// Use zod to validate and type environment variables
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.string().regex(/^d+$/).transform(Number).default('3000'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
REDIS_URL: z.string().url().optional(),
});
// Parse and validate on startup — fail fast if misconfigured
const env = envSchema.safeParse(process.env);
if (!env.success) {
console.error('❌ Invalid environment variables:');
console.error(env.error.flatten().fieldErrors);
process.exit(1);
}
// Export typed config
export const config = env.data;
// Now: config.PORT is number, config.DATABASE_URL is string, etc.
Failing fast on startup is far better than mysterious runtime errors when a missing variable is first accessed.
Environment-Specific Files
Different frameworks handle multiple environments differently:
# Common convention (Vite, Create React App, Next.js)
.env # Base defaults (all environments)
.env.local # Local overrides (gitignored)
.env.development # Development-specific
.env.test # Test-specific
.env.production # Production (committed but no secrets!)
# Loading order (Vite):
# .env → .env.[mode] → .env.local → .env.[mode].local
# Later files override earlier ones
# In package.json scripts:
{
"scripts": {
"dev": "NODE_ENV=development node server.js",
"test": "NODE_ENV=test jest",
"start": "NODE_ENV=production node server.js"
}
}
Docker: Environment Variables
# Dockerfile — never hardcode secrets
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# ✅ Reference env vars with defaults
ENV NODE_ENV=production
ENV PORT=3000
# Don't set secrets here — pass them at runtime
EXPOSE $PORT
CMD ["node", "server.js"]
# docker-compose.yml (development)
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- PORT=3000
# Reference from host environment:
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
env_file:
- .env # Load entire .env file
Docker Secrets (Swarm/Production)
# Create a secret
echo "my-super-secret-password" | docker secret create db_password -
# In docker-compose for Swarm:
services:
app:
image: myapp
secrets:
- db_password
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password
secrets:
db_password:
external: true
# In your app, read the secret file:
const dbPassword = fs.readFileSync(process.env.DB_PASSWORD_FILE, 'utf8').trim();
Kubernetes Secrets
# Create a secret (base64 encoded)
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData: # stringData auto-base64-encodes
database-url: "postgresql://user:pass@postgres:5432/myapp"
jwt-secret: "your-production-jwt-secret"
stripe-key: "sk_live_..."
---
# Reference in Deployment
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
envFrom:
- secretRef:
name: app-secrets
# Or individual env vars:
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-url
# Create secrets from command line (preferred — avoids YAML files with secrets)
kubectl create secret generic app-secrets --from-literal=database-url="postgresql://..." --from-literal=jwt-secret="$(openssl rand -hex 32)"
# View secrets (base64 encoded)
kubectl get secret app-secrets -o yaml
# Decode a specific value
kubectl get secret app-secrets -o jsonpath='{.data.jwt-secret}' | base64 -d
HashiCorp Vault: Enterprise Secrets Management
For large teams and enterprises, Vault provides centralized secrets management with audit trails:
# Start Vault dev server (development only)
vault server -dev
export VAULT_ADDR='http://127.0.0.1:8200'
export VAULT_TOKEN='dev-only-token'
# Store secrets
vault kv put secret/myapp database_url="postgresql://..." jwt_secret="$(openssl rand -hex 32)"
# Read secrets
vault kv get secret/myapp
vault kv get -field=jwt_secret secret/myapp
// Read from Vault in Node.js
import vault from 'node-vault';
const client = vault({
apiVersion: 'v1',
endpoint: process.env.VAULT_ADDR,
token: process.env.VAULT_TOKEN, // Or use AppRole/K8s auth
});
const { data } = await client.read('secret/data/myapp');
const { database_url, jwt_secret } = data.data;
GitHub Actions / CI Secrets
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
STRIPE_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
run: |
# These variables are masked in logs
npm run deploy
Set secrets in: Repository → Settings → Secrets and variables → Actions
Cloud Provider Secret Managers
// AWS Secrets Manager
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({ region: 'us-east-1' });
async function getSecret(secretName) {
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretName })
);
return JSON.parse(response.SecretString);
}
const { database_url, jwt_secret } = await getSecret('myapp/production');
// Google Cloud Secret Manager
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
const client = new SecretManagerServiceClient();
async function getSecret(name) {
const [version] = await client.accessSecretVersion({
name: `projects/MY_PROJECT/secrets/${name}/versions/latest`
});
return version.payload.data.toString();
}
Security Checklist
# Audit your repository for leaked secrets
git log --all --full-history -- "*.env"
git grep -i "password|secret|key|token" -- "*.ts" "*.js" "*.json"
# Use git-secrets to prevent committing secrets
brew install git-secrets
git secrets --install
git secrets --register-aws
# Detect secrets in existing codebase
pip install detect-secrets
detect-secrets scan --baseline .secrets.baseline
# Rotate secrets if you find a leak:
# 1. Revoke the compromised credential immediately
# 2. Generate a new credential
# 3. Update all deployments
# 4. Audit logs for unauthorized access
# 5. Consider git history rewrite (git-filter-repo)
Summary: Right Tool for Each Environment
| Environment | Solution |
|---|---|
| Local Dev | .env file (gitignored) |
| CI/CD | Platform secrets (GitHub Actions, GitLab CI) |
| Docker Dev | env_file in docker-compose |
| Docker Prod | Docker Secrets or mounted volumes |
| Kubernetes | Kubernetes Secrets + Sealed Secrets |
| Cloud | AWS Secrets Manager / GCP Secret Manager |
| Enterprise | HashiCorp Vault |
→ Generate secure random secrets with the Token Generator.