Advanced20 min read

Environment Variables & Config

Secure your application secrets with environment variables, dotenv, and configuration management.

Why Environment Variables?

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:

  1. Git history is permanent. Even if you delete a hardcoded password and commit the change, the old commit with the password still exists in the repository's history. Anyone with access to the repo can find it with git log -p.
  2. Public repositories expose everything. GitHub has automated bots that scan every public push for patterns that look like API keys and passwords. Within minutes of pushing a hardcoded AWS key, attackers can spin up cryptocurrency mining servers on your account. GitHub reports detecting over 10 million secrets in public repos in a single year.
  3. Different environments need different values. Your development database is 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.

The dotenv Package

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:

bash
npm install dotenv

Create a .env file in your project root (the same directory as package.json):

html
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-pass

Load dotenv at the very top of your entry file (before importing anything else):

javascript
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:

  • ALWAYS add .env to .gitignore. This is non-negotiable. If .env is committed to git, every secret in it is compromised forever.
  • No quotes needed around values (unless the value contains spaces).
  • No spaces around =: PORT=3000, not PORT = 3000.
  • All values are strings. process.env.PORT returns the string "3000", not the number 3000. You must parse it: parseInt(process.env.PORT, 10).
  • Comments use #: # 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:

html
PORT=3000
DATABASE_URL=mongodb://localhost:27017/your-db-name
JWT_SECRET=your-jwt-secret-here

New developers clone the repo, copy .env.example to .env, and fill in their own values.

Configuration Module Pattern

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.

javascript
// 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).

Complete Configuration Module

javascript
// ── 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);

Environment-Specific Configuration

Real-world applications run in multiple environments, and each one has different configuration needs.

Development (local machine):

  • Database: local instance (mongodb://localhost:27017/myapp_dev)
  • Logging: verbose, full stack traces
  • Email: uses a test service like Mailtrap (catches emails without sending them)
  • CORS: allows localhost:3000
  • Configuration source: .env file loaded by dotenv

Testing (CI/CD pipeline):

  • Database: separate test database that gets wiped between test runs
  • Logging: minimal, only errors
  • Email: disabled or mocked
  • Configuration source: .env.test file or CI environment variables

Staging (pre-production server):

  • Database: copy of production schema with test data
  • Logging: matches production settings
  • Email: sends to a restricted list (no real customers)
  • Configuration source: hosting platform environment variables

Production (live server):

  • Database: real database with real customer data
  • Logging: structured JSON logs shipped to a monitoring service
  • Email: real email provider (SendGrid, SES)
  • CORS: only allows your actual domain
  • Configuration source: hosting platform secrets management (Heroku Config Vars, AWS Secrets Manager, Docker secrets, Kubernetes ConfigMaps)

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.*.local

Some 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?

Ready to practice?

Create your free account to access the interactive code editor, run challenges, and track your progress.