Back to Blog
Environment VariablesConfigurationDevOpsSecurity

Environment Variables: Best Practices

Manage configuration properly. From local development to production secrets to validation patterns.

B
Bootspring Team
Engineering
October 5, 2022
5 min read

Environment variables configure applications across environments. Here's how to manage them securely and effectively.

Basic Usage#

1// Access environment variables 2const port = process.env.PORT || 3000; 3const dbUrl = process.env.DATABASE_URL; 4const nodeEnv = process.env.NODE_ENV; 5 6// Required variables should fail fast 7const apiKey = process.env.API_KEY; 8if (!apiKey) { 9 throw new Error('API_KEY environment variable is required'); 10} 11 12// .env file (development only) 13// DATABASE_URL=postgresql://localhost:5432/myapp 14// API_KEY=dev_key_123 15// NODE_ENV=development

Type-Safe Configuration#

1import { z } from 'zod'; 2 3// Define schema 4const envSchema = z.object({ 5 NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), 6 PORT: z.string().transform(Number).default('3000'), 7 DATABASE_URL: z.string().url(), 8 REDIS_URL: z.string().url().optional(), 9 API_KEY: z.string().min(1), 10 JWT_SECRET: z.string().min(32), 11 CORS_ORIGINS: z.string().transform((s) => s.split(',')).default(''), 12 LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), 13 ENABLE_METRICS: z.string().transform((s) => s === 'true').default('false'), 14}); 15 16// Parse and validate 17function loadEnv() { 18 const result = envSchema.safeParse(process.env); 19 20 if (!result.success) { 21 console.error('Invalid environment variables:'); 22 console.error(result.error.format()); 23 process.exit(1); 24 } 25 26 return result.data; 27} 28 29export const env = loadEnv(); 30 31// Usage 32console.log(env.PORT); // number 33console.log(env.NODE_ENV); // 'development' | 'production' | 'test' 34console.log(env.CORS_ORIGINS); // string[]

Environment Files#

1# .env.example (committed to repo) 2NODE_ENV=development 3PORT=3000 4DATABASE_URL=postgresql://localhost:5432/myapp 5API_KEY=your_api_key_here 6JWT_SECRET=generate_a_secure_secret 7 8# .env (local development - NOT committed) 9NODE_ENV=development 10PORT=3000 11DATABASE_URL=postgresql://localhost:5432/myapp_dev 12API_KEY=dev_key_123 13JWT_SECRET=dev_secret_at_least_32_characters 14 15# .env.test (test environment) 16NODE_ENV=test 17DATABASE_URL=postgresql://localhost:5432/myapp_test 18 19# .env.production (production template - secrets from secret manager) 20NODE_ENV=production 21PORT=8080 22# DATABASE_URL and secrets come from cloud provider
1# .gitignore 2.env 3.env.local 4.env.*.local 5.env.production 6!.env.example

Loading Environment Variables#

1// dotenv (Node.js) 2import 'dotenv/config'; 3 4// Or with path 5import { config } from 'dotenv'; 6config({ path: '.env.local' }); 7 8// Next.js (automatic) 9// .env.local is loaded automatically 10// Access via process.env.NEXT_PUBLIC_* for client-side 11 12// Vite 13// .env, .env.local, .env.[mode] 14// Access via import.meta.env.VITE_*

Secrets Management#

1// Never commit secrets 2// ❌ Bad 3const apiKey = 'sk_live_abc123'; // Hardcoded secret 4 5// ✓ Good 6const apiKey = process.env.API_KEY; 7 8// Use secret managers in production 9import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; 10 11const client = new SecretsManagerClient({}); 12 13async function getSecret(secretId: string): Promise<Record<string, string>> { 14 const command = new GetSecretValueCommand({ SecretId: secretId }); 15 const response = await client.send(command); 16 return JSON.parse(response.SecretString!); 17} 18 19// Load secrets at startup 20async function loadSecrets() { 21 const secrets = await getSecret('production/app'); 22 process.env.DATABASE_URL = secrets.DATABASE_URL; 23 process.env.API_KEY = secrets.API_KEY; 24} 25 26// Or use environment variables set by infrastructure 27// AWS ECS, Kubernetes, etc. can inject secrets as env vars

Validation Patterns#

1// Validate on startup 2function validateEnv(): void { 3 const required = [ 4 'DATABASE_URL', 5 'JWT_SECRET', 6 'API_KEY', 7 ]; 8 9 const missing = required.filter((key) => !process.env[key]); 10 11 if (missing.length > 0) { 12 throw new Error(`Missing required environment variables: ${missing.join(', ')}`); 13 } 14 15 // Validate formats 16 if (process.env.JWT_SECRET!.length < 32) { 17 throw new Error('JWT_SECRET must be at least 32 characters'); 18 } 19 20 if (!process.env.DATABASE_URL!.startsWith('postgresql://')) { 21 throw new Error('DATABASE_URL must be a PostgreSQL connection string'); 22 } 23} 24 25// Call early in application startup 26validateEnv();

Configuration Object#

1// config.ts - Centralized configuration 2interface Config { 3 env: 'development' | 'production' | 'test'; 4 port: number; 5 database: { 6 url: string; 7 poolSize: number; 8 }; 9 redis: { 10 url: string; 11 } | null; 12 auth: { 13 jwtSecret: string; 14 jwtExpiresIn: string; 15 }; 16 cors: { 17 origins: string[]; 18 }; 19} 20 21function loadConfig(): Config { 22 return { 23 env: (process.env.NODE_ENV as Config['env']) || 'development', 24 port: parseInt(process.env.PORT || '3000', 10), 25 database: { 26 url: requireEnv('DATABASE_URL'), 27 poolSize: parseInt(process.env.DB_POOL_SIZE || '10', 10), 28 }, 29 redis: process.env.REDIS_URL 30 ? { url: process.env.REDIS_URL } 31 : null, 32 auth: { 33 jwtSecret: requireEnv('JWT_SECRET'), 34 jwtExpiresIn: process.env.JWT_EXPIRES_IN || '1h', 35 }, 36 cors: { 37 origins: (process.env.CORS_ORIGINS || '').split(',').filter(Boolean), 38 }, 39 }; 40} 41 42function requireEnv(key: string): string { 43 const value = process.env[key]; 44 if (!value) { 45 throw new Error(`Missing required environment variable: ${key}`); 46 } 47 return value; 48} 49 50export const config = loadConfig(); 51 52// Usage 53import { config } from './config'; 54console.log(config.database.url); 55console.log(config.auth.jwtSecret);

Docker and Kubernetes#

1# Dockerfile 2FROM node:20-alpine 3 4WORKDIR /app 5COPY . . 6RUN npm ci --only=production 7 8# Don't include .env in image 9# Pass env vars at runtime 10CMD ["node", "dist/index.js"]
1# docker-compose.yml 2services: 3 app: 4 build: . 5 environment: 6 - NODE_ENV=production 7 - PORT=3000 8 env_file: 9 - .env.production 10 # Or from shell 11 # environment: 12 # - DATABASE_URL=${DATABASE_URL}
1# kubernetes secret 2apiVersion: v1 3kind: Secret 4metadata: 5 name: app-secrets 6type: Opaque 7stringData: 8 DATABASE_URL: postgresql://user:pass@host:5432/db 9 JWT_SECRET: your-secret-here 10 11--- 12# kubernetes deployment 13apiVersion: apps/v1 14kind: Deployment 15spec: 16 template: 17 spec: 18 containers: 19 - name: app 20 envFrom: 21 - secretRef: 22 name: app-secrets 23 env: 24 - name: NODE_ENV 25 value: production

Best Practices#

Security: ✓ Never commit secrets ✓ Use .env.example for documentation ✓ Use secret managers in production ✓ Rotate secrets regularly Validation: ✓ Validate on startup ✓ Fail fast on missing required vars ✓ Use type-safe configuration ✓ Provide sensible defaults Organization: ✓ Use consistent naming (SCREAMING_SNAKE_CASE) ✓ Group related variables ✓ Document all variables ✓ Use environment-specific files

Conclusion#

Environment variables separate configuration from code. Use .env files for local development, secret managers for production, and always validate configuration at startup. Type-safe configuration objects prevent runtime errors and improve developer experience.

Share this article

Help spread the word about Bootspring