Environment Variables & Configuration in Node.js

Environment Variables & Configuration in Node.js
The same application runs in development, staging, and production — but with different database URLs, different API keys, different log levels, and different feature flags. Environment variables are the mechanism that makes one codebase work in all environments without modification.
This module covers how to use dotenv correctly, how to validate configuration at startup, how to structure a config module, how to manage secrets in production, and how to handle multiple environments cleanly.
This is Module 29 of the Node.js Full‑Stack Developer course.
Installing dotenv
npm install dotenvLoad it as early as possible — before any other imports that might read process.env:
// server.js — first line
import 'dotenv/config';
// Or explicitly:
import dotenv from 'dotenv';
dotenv.config();
import app from './app.js';
// ...The .env File
# .env — local development (never commit this file)
NODE_ENV=development
PORT=3000
# Database
MONGODB_URI=mongodb://localhost:27017/myapp
REDIS_HOST=localhost
REDIS_PORT=6379
# Auth
JWT_ACCESS_SECRET=dev-access-secret-change-in-production
JWT_REFRESH_SECRET=dev-refresh-secret-change-in-production
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=7d
BCRYPT_ROUNDS=10
# OAuth
GOOGLE_CLIENT_ID=your-dev-google-client-id
GOOGLE_CLIENT_SECRET=your-dev-google-client-secret
GITHUB_CLIENT_ID=your-dev-github-client-id
GITHUB_CLIENT_SECRET=your-dev-github-client-secret
# App
API_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5173
ALLOWED_ORIGINS=http://localhost:5173
# Email
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_FROM=no-reply@myapp.dev.gitignore
# .gitignore
.env
.env.local
.env.*.local.env.example — Committed Template
# .env.example — committed to git, no real secrets
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/myapp
REDIS_HOST=localhost
REDIS_PORT=6379
JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=7d
BCRYPT_ROUNDS=12
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
API_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5173Every developer clones the repo, copies .env.example to .env, and fills in their values.
Validating Environment Variables at Startup
Never let a missing variable surface as a runtime error deep in a request handler. Validate everything at startup:
npm install zod// config/env.js
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
MONGODB_URI: z.string().url(),
REDIS_HOST: z.string().default('localhost'),
REDIS_PORT: z.coerce.number().default(6379),
JWT_ACCESS_SECRET: z.string().min(32),
JWT_REFRESH_SECRET: z.string().min(32),
JWT_ACCESS_TTL: z.string().default('15m'),
JWT_REFRESH_TTL: z.string().default('7d'),
BCRYPT_ROUNDS: z.coerce.number().int().min(10).max(14).default(12),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
API_URL: z.string().url(),
FRONTEND_URL: z.string().url(),
ALLOWED_ORIGINS: z.string().default(''),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().optional(),
SMTP_FROM: z.string().email().optional(),
});
function validateEnv() {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:\n');
result.error.issues.forEach(issue => {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
});
process.exit(1); // hard fail — do not start the server
}
return result.data;
}
export const env = validateEnv();If any variable is missing or invalid, the server exits immediately with a clear error:
❌ Invalid environment variables:
MONGODB_URI: Invalid url
JWT_ACCESS_SECRET: String must contain at least 32 character(s)The Config Module
Centralise all configuration in one place. Import env from config/env.js everywhere instead of reading process.env directly:
// config/index.js
import { env } from './env.js';
export const config = {
app: {
nodeEnv: env.NODE_ENV,
port: env.PORT,
isDev: env.NODE_ENV === 'development',
isProd: env.NODE_ENV === 'production',
isTest: env.NODE_ENV === 'test',
apiUrl: env.API_URL,
frontendUrl: env.FRONTEND_URL,
allowedOrigins: env.ALLOWED_ORIGINS.split(',').filter(Boolean),
},
db: {
mongoUri: env.MONGODB_URI,
redisHost: env.REDIS_HOST,
redisPort: env.REDIS_PORT,
},
auth: {
accessSecret: env.JWT_ACCESS_SECRET,
refreshSecret: env.JWT_REFRESH_SECRET,
accessTtl: env.JWT_ACCESS_TTL,
refreshTtl: env.JWT_REFRESH_TTL,
bcryptRounds: env.BCRYPT_ROUNDS,
},
oauth: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
},
},
email: {
host: env.SMTP_HOST,
port: env.SMTP_PORT,
from: env.SMTP_FROM,
},
};Usage throughout the codebase:
// Before — reading process.env directly (fragile)
const port = process.env.PORT || 3000;
const uri = process.env.MONGODB_URI; // might be undefined
// After — typed, validated, with defaults
import { config } from './config/index.js';
const port = config.app.port; // always a number, never undefined
const uri = config.db.mongoUri; // always a valid URL or app already crashedMultiple Environments
Development vs Test vs Production
# .env.test — used during jest runs
NODE_ENV=test
PORT=3001
MONGODB_URI=mongodb://localhost:27017/myapp_test
REDIS_HOST=localhost
JWT_ACCESS_SECRET=test-access-secret-at-least-32-chars-long
JWT_REFRESH_SECRET=test-refresh-secret-at-least-32-chars-long
BCRYPT_ROUNDS=4 # lower rounds for faster tests
API_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3000Load the test env in Jest:
// package.json
{
"jest": {
"testEnvironment": "node",
"setupFiles": ["dotenv/config"],
"testEnvironmentOptions": {
"env": { "DOTENV_CONFIG_PATH": ".env.test" }
}
}
}Or set it explicitly in the Jest globalSetup:
// tests/globalSetup.js
import dotenv from 'dotenv';
export default function () {
dotenv.config({ path: '.env.test' });
}Secrets in Production
Never store production secrets in files on the server. Use a secrets manager:
Option 1: Platform Environment Variables (Simplest)
Railway, Render, Fly.io, Heroku, and Vercel all provide a UI for setting environment variables. They inject them directly into the process at runtime — no .env files on disk.
# Railway CLI example
railway variables set JWT_ACCESS_SECRET=your-production-secret
railway variables set MONGODB_URI=mongodb+srv://...Option 2: AWS Secrets Manager
npm install @aws-sdk/client-secrets-manager// config/secrets.js
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({ region: 'us-east-1' });
export async function getSecret(secretName) {
const command = new GetSecretValueCommand({ SecretId: secretName });
const response = await client.send(command);
return JSON.parse(response.SecretString);
}// server.js
const secrets = await getSecret('myapp/production');
process.env.JWT_ACCESS_SECRET = secrets.jwtAccessSecret;
process.env.MONGODB_URI = secrets.mongoUri;
// Then load the rest of config
const { config } = await import('./config/index.js');Option 3: Doppler (Secrets Manager SaaS)
# Install Doppler CLI
doppler run -- node src/server.jsDoppler injects secrets as environment variables at runtime. No SDK changes, no file changes.
Feature Flags via Environment Variables
// config/features.js
import { env } from './env.js';
export const features = {
googleAuth: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
githubAuth: Boolean(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET),
emailEnabled: Boolean(env.SMTP_HOST),
rateLimiting: env.NODE_ENV === 'production',
requestLogging: env.NODE_ENV !== 'test',
};// app.js
import { features } from './config/features.js';
if (features.googleAuth) {
app.use('/api/v1/auth', authRouter); // includes Google routes
}
if (features.rateLimiting) {
app.use('/api/', apiLimiter);
}Features gracefully degrade based on available configuration — the app runs without Google OAuth if the credentials are not set.
Logging Environment on Startup
// server.js
import { config } from './config/index.js';
import { features } from './config/features.js';
async function start() {
await connectDB();
server.listen(config.app.port, () => {
console.log(`
🚀 Server running on port ${config.app.port}
Environment: ${config.app.nodeEnv}
Database: ${config.db.mongoUri.replace(/:([^:@]+)@/, ':***@')} (password redacted)
Google OAuth: ${features.googleAuth ? '✅' : '❌'}
GitHub OAuth: ${features.githubAuth ? '✅' : '❌'}
Email: ${features.emailEnabled ? '✅' : '❌'}
`);
});
}Node.js Full‑Stack Course — Module 29 of 32
Your application now has bulletproof configuration management. Continue to Module 30 to containerize it with Docker.
Summary
Robust configuration management is the difference between a fragile and a reliable application:
- Load
.envwithimport 'dotenv/config'as the very first line ofserver.js - Commit
.env.examplewith safe defaults — never commit.envwith real secrets - Validate at startup with Zod or Joi — crash immediately on missing config rather than at request time
- Export a typed
configobject fromconfig/index.js— never readprocess.envdirectly in feature code - Use
.env.testwith a test database URL, low bcrypt rounds, andNODE_ENV=test - In production, use platform environment variable UIs or a secrets manager — never
.envfiles on disk - Use feature flags derived from config to gracefully enable/disable optional integrations
Continue to Module 30: Docker Containerization →
