Back to Blog
Node.jsEnvironmentConfigurationSecurity

Node.js Environment Variables

Master environment variables in Node.js. From basics to dotenv to production best practices.

B
Bootspring Team
Engineering
September 30, 2020
6 min read

Environment variables configure applications across environments. Here's how to use them effectively.

Basic Usage#

1// Access environment variables 2const port = process.env.PORT; 3const nodeEnv = process.env.NODE_ENV; 4const apiKey = process.env.API_KEY; 5 6console.log(`Server running on port ${port}`); 7 8// Set environment variables (command line) 9// PORT=3000 node app.js 10// NODE_ENV=production node app.js 11 12// Multiple variables 13// PORT=3000 NODE_ENV=production API_KEY=abc123 node app.js 14 15// Windows (PowerShell) 16// $env:PORT=3000; node app.js 17 18// Windows (Command Prompt) 19// set PORT=3000 && node app.js 20 21// Cross-platform (use cross-env package) 22// npx cross-env PORT=3000 node app.js

Using dotenv#

1// Install: npm install dotenv 2 3// Load at app start 4require('dotenv').config(); 5 6// Or with ES modules 7import 'dotenv/config'; 8 9// Or manually 10import dotenv from 'dotenv'; 11dotenv.config(); 12 13// .env file 14/* 15PORT=3000 16NODE_ENV=development 17DATABASE_URL=postgres://localhost:5432/mydb 18API_KEY=your-secret-key 19DEBUG=true 20*/ 21 22// Access variables 23const config = { 24 port: process.env.PORT || 3000, 25 env: process.env.NODE_ENV || 'development', 26 dbUrl: process.env.DATABASE_URL, 27 apiKey: process.env.API_KEY, 28 debug: process.env.DEBUG === 'true', 29}; 30 31// Multiple .env files 32dotenv.config({ path: '.env.local' }); 33dotenv.config({ path: `.env.${process.env.NODE_ENV}` });

Configuration Module#

1// config.js 2import dotenv from 'dotenv'; 3 4dotenv.config(); 5 6function required(key) { 7 const value = process.env[key]; 8 if (!value) { 9 throw new Error(`Missing required environment variable: ${key}`); 10 } 11 return value; 12} 13 14function optional(key, defaultValue) { 15 return process.env[key] ?? defaultValue; 16} 17 18function boolean(key, defaultValue = false) { 19 const value = process.env[key]; 20 if (value === undefined) return defaultValue; 21 return value === 'true' || value === '1'; 22} 23 24function number(key, defaultValue) { 25 const value = process.env[key]; 26 if (value === undefined) return defaultValue; 27 const parsed = parseInt(value, 10); 28 if (isNaN(parsed)) { 29 throw new Error(`Environment variable ${key} must be a number`); 30 } 31 return parsed; 32} 33 34export const config = { 35 env: optional('NODE_ENV', 'development'), 36 port: number('PORT', 3000), 37 38 database: { 39 url: required('DATABASE_URL'), 40 poolSize: number('DB_POOL_SIZE', 10), 41 }, 42 43 auth: { 44 jwtSecret: required('JWT_SECRET'), 45 jwtExpiry: optional('JWT_EXPIRY', '24h'), 46 }, 47 48 api: { 49 key: required('API_KEY'), 50 rateLimit: number('RATE_LIMIT', 100), 51 }, 52 53 features: { 54 debug: boolean('DEBUG'), 55 analytics: boolean('ENABLE_ANALYTICS', true), 56 }, 57 58 isDev: process.env.NODE_ENV === 'development', 59 isProd: process.env.NODE_ENV === 'production', 60 isTest: process.env.NODE_ENV === 'test', 61}; 62 63// Usage 64import { config } from './config.js'; 65 66console.log(config.port); 67console.log(config.database.url);

TypeScript Configuration#

1// config.ts 2import dotenv from 'dotenv'; 3import { z } from 'zod'; 4 5dotenv.config(); 6 7// Schema definition 8const envSchema = z.object({ 9 NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), 10 PORT: z.string().transform(Number).default('3000'), 11 DATABASE_URL: z.string().url(), 12 JWT_SECRET: z.string().min(32), 13 API_KEY: z.string(), 14 DEBUG: z.string().transform(v => v === 'true').default('false'), 15 RATE_LIMIT: z.string().transform(Number).default('100'), 16}); 17 18// Parse and validate 19const envResult = envSchema.safeParse(process.env); 20 21if (!envResult.success) { 22 console.error('Invalid environment variables:'); 23 console.error(envResult.error.format()); 24 process.exit(1); 25} 26 27export const env = envResult.data; 28 29// Type-safe access 30export const config = { 31 env: env.NODE_ENV, 32 port: env.PORT, 33 database: { 34 url: env.DATABASE_URL, 35 }, 36 auth: { 37 jwtSecret: env.JWT_SECRET, 38 }, 39 api: { 40 key: env.API_KEY, 41 rateLimit: env.RATE_LIMIT, 42 }, 43 debug: env.DEBUG, 44 isDev: env.NODE_ENV === 'development', 45 isProd: env.NODE_ENV === 'production', 46} as const; 47 48export type Config = typeof config;

Environment-Specific Files#

# File structure project/ ├── .env # Default/development ├── .env.local # Local overrides (gitignored) ├── .env.development # Development specific ├── .env.production # Production specific ├── .env.test # Test specific └── .env.example # Template (committed)
1// Load in order 2import dotenv from 'dotenv'; 3import path from 'path'; 4 5const env = process.env.NODE_ENV || 'development'; 6 7// Load files in order (later files override earlier) 8dotenv.config({ path: path.resolve(process.cwd(), '.env') }); 9dotenv.config({ path: path.resolve(process.cwd(), `.env.${env}`) }); 10dotenv.config({ path: path.resolve(process.cwd(), '.env.local') }); 11dotenv.config({ path: path.resolve(process.cwd(), `.env.${env}.local`) }); 12 13// Or use dotenv-flow 14import dotenvFlow from 'dotenv-flow'; 15dotenvFlow.config();

Secrets Management#

1// Never commit secrets to git 2// .gitignore 3/* 4.env 5.env.local 6.env.*.local 7*/ 8 9// Use .env.example for documentation 10/* 11# .env.example 12DATABASE_URL=postgres://user:pass@localhost:5432/db 13JWT_SECRET=your-secret-key-here-minimum-32-chars 14API_KEY=your-api-key 15*/ 16 17// Production secrets via environment 18// Heroku: heroku config:set API_KEY=xxx 19// AWS: Parameter Store or Secrets Manager 20// Docker: --env-file or secrets 21 22// Example: AWS Secrets Manager 23import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; 24 25async function getSecrets() { 26 const client = new SecretsManagerClient({ region: 'us-east-1' }); 27 28 const response = await client.send( 29 new GetSecretValueCommand({ SecretId: 'my-app/production' }) 30 ); 31 32 return JSON.parse(response.SecretString); 33} 34 35// Load secrets in production 36if (process.env.NODE_ENV === 'production') { 37 const secrets = await getSecrets(); 38 Object.assign(process.env, secrets); 39}

Docker Configuration#

1# Dockerfile 2FROM node:18-alpine 3 4WORKDIR /app 5 6COPY package*.json ./ 7RUN npm ci --only=production 8 9COPY . . 10 11# Don't include .env in image 12# Pass at runtime 13 14EXPOSE 3000 15CMD ["node", "server.js"]
1# docker-compose.yml 2version: '3.8' 3 4services: 5 app: 6 build: . 7 ports: 8 - '3000:3000' 9 environment: 10 - NODE_ENV=production 11 - PORT=3000 12 env_file: 13 - .env.production 14 secrets: 15 - db_password 16 - api_key 17 18secrets: 19 db_password: 20 file: ./secrets/db_password.txt 21 api_key: 22 file: ./secrets/api_key.txt

Testing with Environment Variables#

1// test/setup.js 2process.env.NODE_ENV = 'test'; 3process.env.DATABASE_URL = 'postgres://localhost:5432/test_db'; 4process.env.JWT_SECRET = 'test-secret-minimum-32-characters'; 5 6// Jest configuration 7// jest.config.js 8module.exports = { 9 setupFiles: ['<rootDir>/test/setup.js'], 10}; 11 12// Or use dotenv in test setup 13import dotenv from 'dotenv'; 14dotenv.config({ path: '.env.test' }); 15 16// Mock environment variables in tests 17describe('config', () => { 18 const originalEnv = process.env; 19 20 beforeEach(() => { 21 process.env = { ...originalEnv }; 22 }); 23 24 afterAll(() => { 25 process.env = originalEnv; 26 }); 27 28 it('should use default port', () => { 29 delete process.env.PORT; 30 const config = loadConfig(); 31 expect(config.port).toBe(3000); 32 }); 33 34 it('should use custom port', () => { 35 process.env.PORT = '8080'; 36 const config = loadConfig(); 37 expect(config.port).toBe(8080); 38 }); 39});

Validation and Defaults#

1// Comprehensive validation 2class ConfigError extends Error { 3 constructor(message, key) { 4 super(message); 5 this.name = 'ConfigError'; 6 this.key = key; 7 } 8} 9 10function validateConfig() { 11 const errors = []; 12 13 // Required variables 14 const required = ['DATABASE_URL', 'JWT_SECRET', 'API_KEY']; 15 16 for (const key of required) { 17 if (!process.env[key]) { 18 errors.push(`Missing required: ${key}`); 19 } 20 } 21 22 // Format validation 23 if (process.env.PORT && isNaN(parseInt(process.env.PORT))) { 24 errors.push('PORT must be a number'); 25 } 26 27 if (process.env.JWT_SECRET && process.env.JWT_SECRET.length < 32) { 28 errors.push('JWT_SECRET must be at least 32 characters'); 29 } 30 31 if (process.env.DATABASE_URL && !process.env.DATABASE_URL.startsWith('postgres://')) { 32 errors.push('DATABASE_URL must be a valid PostgreSQL connection string'); 33 } 34 35 if (errors.length > 0) { 36 console.error('Configuration errors:'); 37 errors.forEach(err => console.error(` - ${err}`)); 38 process.exit(1); 39 } 40} 41 42// Call on startup 43validateConfig();

Best Practices#

Security: ✓ Never commit .env files ✓ Use .env.example as template ✓ Rotate secrets regularly ✓ Use secret managers in production Organization: ✓ Group related variables ✓ Use consistent naming (UPPER_SNAKE_CASE) ✓ Document all variables ✓ Provide sensible defaults Validation: ✓ Validate on startup ✓ Fail fast on missing required vars ✓ Type-check values ✓ Use schema validation (zod) Development: ✓ Use dotenv for local dev ✓ Keep .env.example updated ✓ Test configuration loading ✓ Mock env vars in tests

Conclusion#

Environment variables are essential for configuration management. Use dotenv for local development, validate configuration on startup, and never commit secrets. Implement a robust configuration module with type safety and validation for production applications.

Share this article

Help spread the word about Bootspring