Complete Environment Variables Management Guide
Sunday, Dec 28, 2025
Ever pushed an API key to GitHub? Or had your application crash in production because of a forgotten environment variable? You’re not alone. Environment variables management is a skill that’s often overlooked, yet it can be the difference between a secure application and a disaster waiting to happen.
In this article, we’ll cover everything from basics to production-ready practices for managing environment variables.
Why Environment Variables Matter
Environment variables are a way to store configuration that differs across environments (development, staging, production) without hardcoding in source code.
Imagine you hardcode like this:
// ❌ NEVER DO THIS
const stripe = new Stripe('sk_live_xxxxxxxxxxxxx');
const dbUrl = 'postgresql://admin:password123@prod-db.com:5432/myapp';
The problems:
- Security risk: Secrets exposed in Git history forever
- Not flexible: Must change code for different environments
- Hard to rotate: If key leaks, must redeploy
With environment variables:
// ✅ The correct way
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const dbUrl = process.env.DATABASE_URL;
.env File Structure
Basic .env File
# .env
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
API_KEY=dev_api_key_12345
JWT_SECRET=super_secret_jwt_key
Multiple Environment Files
Best practice is to have several files for different environments:
project/
├── .env # Default values (committed, no secrets)
├── .env.local # Local overrides (gitignored)
├── .env.development # Development environment
├── .env.staging # Staging environment
├── .env.production # Production values (gitignored, or in CI/CD)
└── .env.example # Template for new developers
.env.example Template
This file is committed to the repo as documentation:
# .env.example
# Copy this file to .env.local and fill with appropriate values
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# Authentication
JWT_SECRET=your-jwt-secret-here
JWT_EXPIRES_IN=7d
# External APIs
STRIPE_SECRET_KEY=sk_test_xxx
SENDGRID_API_KEY=SG.xxx
# Feature Flags
ENABLE_NEW_DASHBOARD=false
Client vs Server Environment Variables
This is very important for frameworks like Next.js, Nuxt, or SvelteKit.
Next.js Convention
# Server-side only (not exposed to browser)
DATABASE_URL=postgresql://...
STRIPE_SECRET_KEY=sk_live_xxx
# Client-side accessible (exposed to browser!)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
NEXT_PUBLIC_API_URL=https://api.example.com
Simple rule:
NEXT_PUBLIC_*= accessible in browser (DON’T put secrets!)- Without prefix = server-side only
Why is This Important?
// ❌ DANGEROUS - secret exposed to browser
// In React component
const apiKey = process.env.STRIPE_SECRET_KEY; // undefined on client
// ✅ SAFE - server only
// In API route or getServerSideProps
export async function getServerSideProps() {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// ...
}
Other Frameworks
| Framework | Client Prefix | Server Default |
|---|---|---|
| Next.js | NEXT_PUBLIC_ | No prefix |
| Nuxt 3 | NUXT_PUBLIC_ | NUXT_ |
| Vite | VITE_ | No server access |
| Create React App | REACT_APP_ | All exposed! |
| SvelteKit | PUBLIC_ | No prefix |
Dotenv and Environment Loading
Basic Setup with dotenv
npm install dotenv
// src/config.ts
import dotenv from 'dotenv';
// Load .env file
dotenv.config();
// Or load specific file
dotenv.config({ path: '.env.local' });
Loading Order (Next.js Style)
// Load based on NODE_ENV
import dotenv from 'dotenv';
import path from 'path';
const envFiles = [
`.env.${process.env.NODE_ENV}.local`,
`.env.local`,
`.env.${process.env.NODE_ENV}`,
'.env',
];
envFiles.forEach((file) => {
dotenv.config({ path: path.resolve(process.cwd(), file) });
});
Environment Validation with Zod
Don’t trust environment variables without validation. Better for the app to crash at startup than random runtime errors.
Setup Zod Validation
npm install zod
// src/env.ts
import { z } from 'zod';
const envSchema = z.object({
// Database
DATABASE_URL: z.string().url(),
// Server
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
// Authentication
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
JWT_EXPIRES_IN: z.string().default('7d'),
// External Services
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
// Optional with defaults
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
ENABLE_CACHE: z.coerce.boolean().default(true),
});
// Parse and validate
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Invalid environment variables:');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
Usage
import { env } from './env';
// Type-safe and validated!
console.log(env.DATABASE_URL); // string
console.log(env.PORT); // number
console.log(env.ENABLE_CACHE); // boolean
T3 Env (Recommended for Next.js)
npm install @t3-oss/env-nextjs zod
// src/env.mjs
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().min(1),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
});
Secrets Management for Production
Vercel
# Via CLI
vercel env add STRIPE_SECRET_KEY production
vercel env add DATABASE_URL production
# Pull env to local
vercel env pull .env.local
In Vercel Dashboard:
- Settings → Environment Variables
- Can set per environment (Production, Preview, Development)
- Sensitive values automatically encrypted
Railway
# Via CLI
railway variables set STRIPE_SECRET_KEY=sk_live_xxx
# Or link to service
railway link
railway variables
Railway has Reference Variables feature:
# Reference from database service
DATABASE_URL=${{Postgres.DATABASE_URL}}
AWS (Parameter Store & Secrets Manager)
Parameter Store (for regular config):
aws ssm put-parameter \
--name "/myapp/production/API_KEY" \
--value "secret_value" \
--type SecureString
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
const ssm = new SSMClient({ region: "us-east-1" });
async function getParameter(name: string) {
const command = new GetParameterCommand({
Name: name,
WithDecryption: true,
});
const response = await ssm.send(command);
return response.Parameter?.Value;
}
const apiKey = await getParameter("/myapp/production/API_KEY");
Secrets Manager (for frequently rotated secrets):
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
const client = new SecretsManagerClient({ region: "us-east-1" });
async function getSecret(secretName: string) {
const command = new GetSecretValueCommand({ SecretId: secretName });
const response = await client.send(command);
return JSON.parse(response.SecretString || '{}');
}
const dbCredentials = await getSecret("myapp/database");
HashiCorp Vault
For enterprise-grade secrets management:
import Vault from 'node-vault';
const vault = Vault({
apiVersion: 'v1',
endpoint: 'https://vault.example.com:8200',
token: process.env.VAULT_TOKEN,
});
const secret = await vault.read('secret/data/myapp/production');
const dbPassword = secret.data.data.DB_PASSWORD;
12-Factor App Principles
From 12factor.net, principle #3 about Config:
Store config in the environment
What Counts as “Config”?
✅ Should be in environment variables:
- Database credentials
- API keys and secrets
- External service URLs
- Feature flags per environment
❌ Not config (stays in code):
- Routes and URL patterns
- Dependency injection config
- Internal business logic settings
12-Factor Implementation
// ✅ Good: Config from environment
const config = {
db: {
url: process.env.DATABASE_URL,
poolSize: parseInt(process.env.DB_POOL_SIZE || '10'),
},
redis: {
url: process.env.REDIS_URL,
},
features: {
newCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true',
},
};
// ❌ Bad: Hardcoded per environment
const config = {
db: {
url: process.env.NODE_ENV === 'production'
? 'postgresql://prod...'
: 'postgresql://localhost...',
},
};
Gitignore Best Practices
.gitignore for Environment Files
# Environment files
.env
.env.local
.env.*.local
.env.development.local
.env.test.local
.env.production.local
.env.staging
# Keep example file
!.env.example
# IDE
.idea/
.vscode/
# OS
.DS_Store
Pre-commit Hook to Prevent Secret Leaks
Install git-secrets:
# macOS
brew install git-secrets
# Setup in repo
git secrets --install
git secrets --register-aws
Or use gitleaks:
# Install
brew install gitleaks
# Scan repo
gitleaks detect --source . -v
GitHub Secret Scanning
Enable in Settings → Code security → Secret scanning. GitHub will alert if secrets are pushed.
Development vs Production Environment
Separation of Concerns
// src/config/index.ts
import { z } from 'zod';
const baseSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']),
});
const developmentSchema = baseSchema.extend({
DATABASE_URL: z.string().default('postgresql://localhost:5432/myapp_dev'),
DEBUG: z.coerce.boolean().default(true),
});
const productionSchema = baseSchema.extend({
DATABASE_URL: z.string().url(),
DEBUG: z.literal(false).default(false),
SENTRY_DSN: z.string().url(),
});
export const env = process.env.NODE_ENV === 'production'
? productionSchema.parse(process.env)
: developmentSchema.parse(process.env);
Environment-Specific Behavior
// src/lib/logger.ts
import { env } from '../config';
export const logger = {
debug: (...args: unknown[]) => {
if (env.LOG_LEVEL === 'debug') {
console.debug('[DEBUG]', ...args);
}
},
info: (...args: unknown[]) => {
console.info('[INFO]', ...args);
},
error: (...args: unknown[]) => {
console.error('[ERROR]', ...args);
// Send to Sentry in production
if (env.NODE_ENV === 'production') {
// Sentry.captureException(args[0]);
}
},
};
Environment Variables in Docker
Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Don't hardcode ENV in Dockerfile!
# ENV DATABASE_URL=xxx ❌
# Use ARG for build-time variables
ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV
EXPOSE 3000
CMD ["node", "dist/index.js"]
Docker Compose
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- LOG_LEVEL=debug
env_file:
- .env.local
depends_on:
- db
- redis
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
POSTGRES_DB: ${DB_NAME:-myapp}
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
postgres_data:
Docker Secrets (Swarm Mode)
# docker-compose.prod.yml
version: '3.8'
services:
app:
image: myapp:latest
secrets:
- db_password
- stripe_key
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
STRIPE_KEY_FILE: /run/secrets/stripe_key
secrets:
db_password:
external: true
stripe_key:
external: true
// Read from file in production
import fs from 'fs';
function getSecret(envVar: string): string {
const fileEnv = `${envVar}_FILE`;
if (process.env[fileEnv]) {
return fs.readFileSync(process.env[fileEnv], 'utf8').trim();
}
return process.env[envVar] || '';
}
const dbPassword = getSecret('DB_PASSWORD');
Common Mistakes and How to Avoid Them
1. Committing .env to Git
# If already committed
git rm --cached .env
echo ".env" >> .gitignore
git commit -m "Remove .env from tracking"
# But secret is already in history! Must rotate all keys.
Solution: Use pre-commit hooks and secret scanning.
2. Not Validating Environment Variables
// ❌ Crash at runtime
const port = parseInt(process.env.PORT); // NaN if undefined!
// ✅ Validate at startup
const port = z.coerce.number().default(3000).parse(process.env.PORT);
3. Hardcoded Conditionals per Environment
// ❌ Hard to maintain
const apiUrl = process.env.NODE_ENV === 'production'
? 'https://api.prod.com'
: process.env.NODE_ENV === 'staging'
? 'https://api.staging.com'
: 'http://localhost:3001';
// ✅ Single source of truth
const apiUrl = process.env.API_URL;
4. Forgetting to Set in Production
// Checklist before deploy
const requiredEnvs = [
'DATABASE_URL',
'JWT_SECRET',
'STRIPE_SECRET_KEY',
];
requiredEnvs.forEach((env) => {
if (!process.env[env]) {
throw new Error(`Missing required env: ${env}`);
}
});
5. Exposing Secrets on Client-Side
// ❌ NEXT_PUBLIC_ means exposed to browser!
NEXT_PUBLIC_DATABASE_URL=xxx // DANGEROUS!
NEXT_PUBLIC_API_SECRET=xxx // DANGEROUS!
// ✅ Only public keys for client
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
NEXT_PUBLIC_ANALYTICS_ID=G-XXXXXXX
6. Not Rotating Secrets
Create reminders to rotate secrets regularly:
- API keys: every 90 days
- Database passwords: every 30-90 days
- JWT secrets: every 180 days
7. Logging Environment Variables
// ❌ DON'T log secrets!
console.log('Config:', process.env);
console.log('DB URL:', process.env.DATABASE_URL);
// ✅ Mask sensitive values
const safeConfig = {
NODE_ENV: process.env.NODE_ENV,
DATABASE_URL: process.env.DATABASE_URL?.replace(/\/\/.*@/, '//***@'),
API_KEY: process.env.API_KEY ? '***' : 'not set',
};
console.log('Config:', safeConfig);
Environment Variables Checklist
Before deploying, make sure:
- All
.env*files (except.env.example) are in.gitignore - Environment variables are validated at startup
- No hardcoded secrets in code
- Client-side variables don’t contain secrets
- All required env vars are set in production
- Pre-commit hook to detect secrets
- Secret scanning enabled on GitHub
-
.env.exampledocumentation is up to date - Secrets rotation schedule
Conclusion
Environment variables management isn’t just about creating a .env file. It’s about:
- Security - Never commit secrets
- Validation - Fail fast with Zod
- Separation - Client vs Server, Dev vs Prod
- Automation - Secret scanning and pre-commit hooks
- Documentation -
.env.examplethat’s always updated
Start simple: set up correct .gitignore, validate with Zod, and use managed secrets on production platforms like Vercel or Railway.
If you have questions or want to discuss further, reach out on Twitter or comment below. Happy coding! 🔐