Why Environment Variables?
Environment variables separate configuration from code. They let you run the same codebase in different environments (development, staging, production) with different settings -- without changing a single line of code.
Hardcoding configuration is dangerous:
// NEVER do this
const DATABASE_URL = 'postgres://admin:[email protected]:5432/myapp';
const STRIPE_KEY = 'sk_live_abc123xyz';
const JWT_SECRET = 'my-super-secret-jwt-key';
This is in your source code. Anyone with repo access sees your production credentials. If the repo is public, the entire internet does.
The fix: environment variables.
// Always do this
const DATABASE_URL = process.env.DATABASE_URL;
const STRIPE_KEY = process.env.STRIPE_KEY;
const JWT_SECRET = process.env.JWT_SECRET;
The values come from the environment, not the code. Different environments provide different values. Secrets never enter version control.
How Environment Variables Work
Environment variables are key-value pairs available to any process running in the operating system.
# Set a variable in the shell
export API_URL=https://api.example.com
# Access it in the same shell
echo $API_URL
# Output: https://api.example.com
# Pass it to a specific command
DATABASE_URL=postgres://localhost/mydb node server.js
# List all environment variables
env
printenv
In Node.js, access them via process.env:
// Access environment variables
const port = process.env.PORT || 3000;
const nodeEnv = process.env.NODE_ENV || 'development';
const dbUrl = process.env.DATABASE_URL;
// They're always strings
console.log(typeof process.env.PORT); // "string"
// Convert when needed
const maxRetries = parseInt(process.env.MAX_RETRIES || '3', 10);
const enableCache = process.env.ENABLE_CACHE === 'true';
In the browser (frontend), environment variables are handled differently. Build tools inject them at build time:
// Next.js: NEXT_PUBLIC_ prefix makes it available in the browser
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
// Vite: VITE_ prefix
const apiUrl = import.meta.env.VITE_API_URL;
// Create React App: REACT_APP_ prefix
const apiUrl = process.env.REACT_APP_API_URL;
.env Files and dotenv
Typing export KEY=value for 20 variables every time you start development is impractical. .env files solve this.
Basic .env File
# .env.local (development -- NEVER commit this file)
# Server
PORT=3000
NODE_ENV=development
# Database
DATABASE_URL=postgres://dev:devpass@localhost:5432/myapp_dev
# Authentication
JWT_SECRET=local-dev-jwt-secret-not-for-production
JWT_EXPIRES_IN=7d
# External APIs
STRIPE_SECRET_KEY=sk_test_abc123
STRIPE_WEBHOOK_SECRET=whsec_test_xyz789
# Email
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USER=
SMTP_PASS=
# Feature Flags
ENABLE_NEW_CHECKOUT=true
ENABLE_ANALYTICS=false
# Frontend (Next.js)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_abc123
Loading .env Files
# Install dotenv
npm install dotenv
// Load at the very start of your application
// src/server.ts
import 'dotenv/config';
// or
import dotenv from 'dotenv';
dotenv.config();
// Now process.env has all values from .env
console.log(process.env.DATABASE_URL);
Most modern frameworks load .env files automatically:
Next.js: Loads .env, .env.local, .env.development, .env.production automatically
Vite: Loads .env, .env.local, .env.development, .env.production automatically
Remix: Requires dotenv or similar manual setup
Express: Requires dotenv manual setup
.env File Priority
Next.js and Vite load files in this order (later files override earlier):
.env Base defaults (committed to repo)
.env.local Local overrides (NOT committed)
.env.development Development-specific (committed)
.env.development.local Local dev overrides (NOT committed)
.env.production Production defaults (committed)
.env.production.local Production local overrides (NOT committed)
Committed to git: NOT committed to git:
.env .env.local
.env.development .env.development.local
.env.production .env.production.local
Dev, Staging, and Production Configs
Different environments need different configurations. Here's a practical setup.
Environment Configuration Map
+--------------+---------------------+-----------------------------+
| Variable | Development | Staging | Production |
+--------------+---------------------+-----------------------------+
| NODE_ENV | development | staging | production |
| DATABASE_URL | localhost/myapp_dev | rds.../staging | rds.../prod|
| LOG_LEVEL | debug | debug | info |
| STRIPE_KEY | sk_test_... | sk_test_... | sk_live_...|
| API_URL | localhost:3000 | staging.app.io | api.app.io |
| ENABLE_DEBUG | true | true | false |
| SENTRY_DSN | (empty) | dsn://staging | dsn://prod |
+--------------+---------------------+-----------------------------+
Configuration Validation
Don't let your app start with missing config. Validate at startup.
// src/config.ts
type Config = {
port: number;
nodeEnv: string;
databaseUrl: string;
jwtSecret: string;
stripeSecretKey: string;
smtpHost: string;
smtpPort: number;
};
function getRequiredEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
function getOptionalEnv(key: string, defaultValue: string): string {
return process.env[key] || defaultValue;
}
export const config: Config = {
port: parseInt(getOptionalEnv('PORT', '3000'), 10),
nodeEnv: getOptionalEnv('NODE_ENV', 'development'),
databaseUrl: getRequiredEnv('DATABASE_URL'),
jwtSecret: getRequiredEnv('JWT_SECRET'),
stripeSecretKey: getRequiredEnv('STRIPE_SECRET_KEY'),
smtpHost: getOptionalEnv('SMTP_HOST', 'localhost'),
smtpPort: parseInt(getOptionalEnv('SMTP_PORT', '587'), 10),
};
// Validation with Zod (more robust)
import { z } from 'zod';
const envSchema = z.object({
PORT: z.string().default('3000').transform(Number),
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
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_'),
});
export const env = envSchema.parse(process.env);
// Output on missing variable:
// Error: Missing required environment variable: DATABASE_URL
// The app crashes immediately with a clear message, instead of
// failing later with a cryptic "Cannot read properties of undefined"
Secrets Management
Secrets are a special category of environment variables: API keys, database passwords, JWT secrets, encryption keys. They require extra care.
The Cardinal Rule
Never commit secrets to version control. Ever.
Not "just for now." Not "it's a private repo." Not "I'll remove it later." Git history is permanent. Once a secret is in a commit, it's compromised.
What Counts as a Secret
Definitely secrets:
- Database passwords
- API keys (Stripe, AWS, SendGrid)
- JWT signing keys
- Encryption keys
- OAuth client secrets
- SMTP credentials
- SSH private keys
- Service account credentials
NOT secrets (but still environment-specific):
- Port numbers
- Feature flags
- API base URLs
- Log levels
- Public keys (publishable Stripe key, OAuth client ID)
.gitignore for Secrets
# .gitignore -- MUST include these
.env
.env.local
.env.*.local
*.pem
*.key
credentials.json
service-account.json
Where to Store Secrets
| Method | Best For | Security Level |
|---|---|---|
.env.local (gitignored) | Local development | Low (plaintext on disk) |
| CI/CD secrets (GitHub, GitLab) | Build/deploy pipelines | Medium |
| Cloud secret managers (AWS SSM, GCP Secret Manager) | Production | High |
| HashiCorp Vault | Enterprise, multi-cloud | Very High |
| 1Password / Bitwarden (team) | Sharing secrets among devs | Medium-High |
Cloud Secret Managers
// AWS Systems Manager Parameter Store
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';
const ssm = new SSMClient({ region: 'us-east-1' });
async function getSecret(name: string): Promise<string> {
const command = new GetParameterCommand({
Name: name,
WithDecryption: true,
});
const response = await ssm.send(command);
return response.Parameter?.Value || '';
}
// Usage
const dbPassword = await getSecret('/myapp/production/DATABASE_PASSWORD');
// Google Cloud Secret Manager
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
const client = new SecretManagerServiceClient();
async function getSecret(name: string): Promise<string> {
const [version] = await client.accessSecretVersion({
name: `projects/my-project/secrets/${name}/versions/latest`,
});
return version.payload?.data?.toString() || '';
}
Secret Rotation
Secrets should be rotated (changed) regularly. If a secret is compromised, rotation limits the damage window.
Rotation Strategy
+------------------+-------------------+
| Secret Type | Rotation Frequency|
+------------------+-------------------+
| API keys | 90 days |
| Database passwords| 90 days |
| JWT signing keys | 30-90 days |
| Encryption keys | Annually |
| OAuth secrets | 90 days |
| SSH keys | Annually |
+------------------+-------------------+
Zero-Downtime Rotation
The key challenge: you can't change a secret everywhere simultaneously. During rotation, both old and new secrets must work.
// JWT rotation with dual validation
const JWT_SECRETS = [
process.env.JWT_SECRET_CURRENT, // new key -- used for signing
process.env.JWT_SECRET_PREVIOUS, // old key -- still valid for verification
].filter(Boolean);
// Sign with current key
function signToken(payload: TokenPayload): string {
return jwt.sign(payload, JWT_SECRETS[0], { expiresIn: '7d' });
}
// Verify with any valid key
function verifyToken(token: string): TokenPayload | null {
for (const secret of JWT_SECRETS) {
try {
return jwt.verify(token, secret) as TokenPayload;
} catch {
continue; // try next key
}
}
return null; // no key worked
}
Rotation steps:
1. Generate new secret
2. Add new secret as JWT_SECRET_CURRENT
3. Move old secret to JWT_SECRET_PREVIOUS
4. Deploy -- new tokens signed with new key, old tokens still valid
5. Wait for old tokens to expire (e.g., 7 days)
6. Remove JWT_SECRET_PREVIOUS
7. Deploy final state
CI/CD Secret Injection
CI/CD pipelines need secrets for deployment, API calls, and artifact publishing. Each platform has its own mechanism.
GitHub Actions Secrets
# Set secrets in GitHub:
# Repo Settings > Secrets and variables > Actions > New repository secret
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: npm run build
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Deploy
run: ./deploy.sh
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
GitHub Environments
Use environments to scope secrets to specific deployments:
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging # Uses staging secrets
steps:
- run: echo "Deploying to staging"
env:
API_KEY: ${{ secrets.API_KEY }} # staging API key
deploy-production:
runs-on: ubuntu-latest
environment: production # Uses production secrets, may require approval
needs: deploy-staging
steps:
- run: echo "Deploying to production"
env:
API_KEY: ${{ secrets.API_KEY }} # production API key
Docker Secret Injection
# DON'T bake secrets into images
# Bad:
ENV API_KEY=sk_live_abc123
# DO pass secrets at runtime
# docker run -e API_KEY=sk_live_abc123 my-app
# docker-compose.yml with .env file
services:
app:
build: .
env_file:
- .env.production
# or individual variables:
environment:
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
Secret Scanning
Automated tools catch secrets accidentally committed to code.
Git Pre-Commit Hook (gitleaks)
# Install gitleaks
brew install gitleaks
# Scan your repository
gitleaks detect --source . --verbose
# Add as pre-commit hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
GitHub Secret Scanning
GitHub automatically scans public repos (and private repos on Enterprise) for known secret patterns:
Detected patterns include:
- AWS access keys
- GitHub tokens
- Stripe API keys
- Google Cloud keys
- Azure connection strings
- npm tokens
- Slack webhooks
- And 100+ more providers
Enable in repo settings: Settings > Code security and analysis > Secret scanning.
.gitleaks.toml Configuration
# .gitleaks.toml
title = "Gitleaks Configuration"
[allowlist]
description = "Allow specific patterns"
paths = [
'''\.env\.example''',
'''\.env\.template''',
'''docs/''',
]
# Custom rules
[[rules]]
id = "custom-api-key"
description = "Custom API key pattern"
regex = '''CUSTOM_API_KEY\s*=\s*['\"]?[a-zA-Z0-9]{32,}['\"]?'''
tags = ["key", "custom"]
What To Do When a Secret Is Exposed
It happens. A secret gets committed, pushed, or leaked. Act fast.
IMMEDIATE (within minutes):
1. Revoke/rotate the exposed secret at the provider
- AWS: deactivate the access key in IAM
- Stripe: roll the API key in dashboard
- Database: change the password
2. Deploy with the new secret
3. Check access logs for unauthorized use
FOLLOW-UP (within hours):
4. Remove the secret from git history (if committed)
git filter-repo --invert-paths --path .env
5. Force push the cleaned history (coordinate with team)
6. Audit what the secret had access to
7. Document the incident
PREVENT (within days):
8. Add the file pattern to .gitignore
9. Set up pre-commit secret scanning
10. Enable GitHub secret scanning
11. Review team practices
Example: Complete Environment Setup
Here's a full setup for a Next.js application with proper environment management.
File Structure
project/
.env # Defaults (committed, no secrets)
.env.local # Local overrides (gitignored)
.env.example # Template for developers (committed)
.gitignore
src/
config.ts # Validated config object
.env (committed -- defaults only)
# .env -- safe defaults, no secrets
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3000/api
.env.example (committed -- template)
# .env.example -- copy to .env.local and fill in values
# cp .env.example .env.local
# Server
PORT=3000
NODE_ENV=development
# Database (required)
DATABASE_URL=postgres://user:password@localhost:5432/myapp
# Authentication (required)
JWT_SECRET=generate-a-long-random-string-at-least-32-chars
# Stripe (required for payments)
STRIPE_SECRET_KEY=sk_test_your_key_here
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_key_here
# Email (optional -- falls back to console logging)
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
src/config.ts (validation)
import { z } from 'zod';
const envSchema = z.object({
// Server
PORT: z.string().default('3000').transform(Number),
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Database
DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL'),
// Auth
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
// Stripe
STRIPE_SECRET_KEY: z.string().startsWith('sk_', 'STRIPE_SECRET_KEY must start with sk_'),
// Email (optional)
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional().transform((v) => (v ? Number(v) : undefined)),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
});
function validateEnv() {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Environment validation failed:');
for (const issue of result.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
}
process.exit(1);
}
return result.data;
}
export const env = validateEnv();
.gitignore
# Environment files with secrets
.env.local
.env.development.local
.env.staging.local
.env.production.local
# Keys and credentials
*.pem
*.key
*.p12
credentials.json
service-account*.json
Key Takeaways
- Never hardcode secrets in source code -- use environment variables
- Use
.envfiles for local development, gitignore all.localvariants - Validate config at startup -- crash early with clear messages rather than failing later with cryptic errors
- Different environments (dev/staging/prod) need different values -- same code, different config
- Store production secrets in cloud secret managers (AWS SSM, GCP Secret Manager), not in
.envfiles - Rotate secrets regularly (every 90 days for API keys and passwords)
- Use pre-commit hooks (gitleaks) and GitHub secret scanning to catch leaks
- When a secret is exposed: revoke immediately, rotate, audit, clean git history
- Provide a
.env.examplefile so developers know which variables are needed - Frontend variables must use framework-specific prefixes (
NEXT_PUBLIC_,VITE_) to be accessible in the browser