Secure your application secrets with environment variables, dotenv, and configuration management.
Every application has secrets — database passwords, API keys, JWT signing secrets, payment gateway tokens, email service credentials. These values are essential for your application to function, but they are also incredibly dangerous if exposed.
Never hardcode secrets in your source code. This is one of the most critical security rules in software development, and it is violated constantly by beginners. Here is why:
git log -p.localhost:5432/myapp_dev. Your staging database is staging-db.internal:5432/myapp_staging. Your production database is a completely different server with different credentials. You cannot hardcode one value and have it work everywhere.Environment variables solve all of these problems. They are key-value pairs set outside your application code, at the operating system or deployment level. Your code reads them at runtime using process.env.VARIABLE_NAME. The actual values live in a .env file (locally) or in your hosting platform's configuration (in production) — never in your source code.
This approach follows the twelve-factor app methodology, an industry-standard guide for building modern, deployable applications. Factor III states: "Store config in the environment." Configuration that varies between deployments (database URLs, API keys, feature flags) belongs in environment variables, not in code.
While production environments (Heroku, AWS, Docker) have built-in ways to set environment variables, your local development machine does not. You need a way to define environment variables for your project without setting them globally on your operating system. This is where dotenv comes in.
Installation:
npm install dotenvCreate a .env file in your project root (the same directory as package.json):
PORT=3000
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=my-super-secret-key-change-this-in-production
JWT_EXPIRES_IN=7d
NODE_ENV=development
SMTP_HOST=smtp.mailtrap.io
SMTP_PORT=587
SMTP_USER=your-mailtrap-user
SMTP_PASS=your-mailtrap-passLoad dotenv at the very top of your entry file (before importing anything else):
require('dotenv').config();
// Now process.env.PORT is '3000'
// Now process.env.JWT_SECRET is 'my-super-secret-key-change-this-in-production'Critical rules for .env files:
.env to .gitignore. This is non-negotiable. If .env is committed to git, every secret in it is compromised forever.=: PORT=3000, not PORT = 3000.process.env.PORT returns the string "3000", not the number 3000. You must parse it: parseInt(process.env.PORT, 10).#: # This is a comment.DO commit a .env.example file with placeholder values. This serves as documentation for your team — it lists every environment variable the app needs without revealing actual secrets:
PORT=3000
DATABASE_URL=mongodb://localhost:27017/your-db-name
JWT_SECRET=your-jwt-secret-hereNew developers clone the repo, copy .env.example to .env, and fill in their own values.
Scattering process.env.SOME_VAR calls throughout your codebase creates several problems. First, there is no validation — if a required variable is missing, your app might start successfully and only crash later when it tries to use the missing value. Second, there is no type conversion — every process.env value is a string, so you end up writing parseInt() everywhere. Third, there is no single source of truth — to understand what configuration your app uses, you have to search every file.
The solution is a configuration module: a single file that reads, validates, converts, and exports all environment variables. Your entire application imports from this one file.
// config.js
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
const config = {
port: parseInt(process.env.PORT, 10) || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
isProduction: process.env.NODE_ENV === 'production',
isDevelopment: process.env.NODE_ENV === 'development',
db: {
url: requireEnv('DATABASE_URL'),
},
jwt: {
secret: requireEnv('JWT_SECRET'),
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
},
smtp: {
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT, 10) || 587,
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
};
module.exports = config;This approach provides multiple benefits: fail-fast behavior (the app throws an error at startup if required variables are missing, not at runtime when a user triggers the code path), type safety (numbers are parsed, booleans are computed), organization (related config is grouped together), and discoverability (one file lists every external dependency your app has).
// ── config.js ─ Centralized Configuration ────────────
require('dotenv').config();
/**
* Reads an environment variable and throws if it is not set.
* Call this for variables that MUST exist (secrets, database URLs).
*/
function requireEnv(name) {
const value = process.env[name];
if (value === undefined || value === '') {
throw new Error(
`FATAL: Missing required environment variable: ${name}.\n` +
`Check your .env file or deployment configuration.`
);
}
return value;
}
const config = {
// ── Server ────────────────────────────────────────
port: parseInt(process.env.PORT, 10) || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
isProduction: process.env.NODE_ENV === 'production',
isDevelopment: process.env.NODE_ENV !== 'production',
// ── Database (required) ───────────────────────────
db: {
url: requireEnv('DATABASE_URL'),
},
// ── Authentication (required) ─────────────────────
jwt: {
secret: requireEnv('JWT_SECRET'),
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
cookieName: 'token',
},
// ── Email (optional in development) ───────────────
smtp: {
host: process.env.SMTP_HOST || 'smtp.mailtrap.io',
port: parseInt(process.env.SMTP_PORT, 10) || 587,
user: process.env.SMTP_USER || '',
pass: process.env.SMTP_PASS || '',
},
// ── CORS ──────────────────────────────────────────
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
},
// ── Rate Limiting ─────────────────────────────────
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW, 10) || 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100,
},
};
// ── Validation summary at startup ───────────────────
console.log(`[Config] Environment: ${config.nodeEnv}`);
console.log(`[Config] Port: ${config.port}`);
console.log(`[Config] Database: ${config.db.url.substring(0, 20)}...`);
module.exports = config;
// ── Usage in other files ────────────────────────────
// const config = require('./config');
// app.listen(config.port);
// jwt.sign(payload, config.jwt.secret);Real-world applications run in multiple environments, and each one has different configuration needs.
Development (local machine):
mongodb://localhost:27017/myapp_dev)localhost:3000.env file loaded by dotenvTesting (CI/CD pipeline):
.env.test file or CI environment variablesStaging (pre-production server):
Production (live server):
You should NEVER commit .env files. But you SHOULD commit:
.env.example — documents every variable with placeholder values.gitignore — must contain .env, .env.local, .env.*.localSome teams use a library like dotenv-flow which automatically loads the right .env file based on NODE_ENV. For example, if NODE_ENV=test, it loads .env.test first, then falls back to .env for any values not overridden.
In production, never use .env files at all. Set variables through your hosting platform's dashboard or CLI. This keeps secrets out of the file system entirely, reducing the risk of exposure through file traversal vulnerabilities or accidental file sharing.
Where should you store your database password?