Express apps are notorious for becoming sprawling messes of environment-specific configuration.

// app.js (before)
const express = require('express');
const app = express();

// Database config
const dbHost = process.env.NODE_ENV === 'production' ? 'prod.db.com' : 'dev.db.com';
const dbPort = process.env.NODE_ENV === 'production' ? 5432 : 5433;
const dbUser = process.env.NODE_ENV === 'production' ? 'prod_user' : 'dev_user';
const dbPass = process.env.NODE_ENV === 'production' ? 'prod_secret' : 'dev_secret';

// API keys
const stripeKey = process.env.NODE_ENV === 'production' ? 'sk_live_...' : 'sk_test_...';

// Server port
const port = process.env.NODE_ENV === 'production' ? 80 : 3000;

// ... rest of app setup ...

app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

This approach quickly becomes unmanageable. The core problem is that process.env is a global, untyped, and often inconsistent source of truth for everything from database credentials to feature flags.

Instead, let’s manage configuration using a dedicated library like dotenv and a structured configuration object.

First, install dotenv:

npm install dotenv --save

Then, create .env files for different environments.

.env.development:

NODE_ENV=development
PORT=3000
DATABASE_HOST=dev.db.com
DATABASE_PORT=5433
DATABASE_USER=dev_user
DATABASE_PASSWORD=dev_secret
STRIPE_SECRET_KEY=sk_test_...

.env.production:

NODE_ENV=production
PORT=80
DATABASE_HOST=prod.db.com
DATABASE_PORT=5432
DATABASE_USER=prod_user
DATABASE_PASSWORD=prod_secret
STRIPE_SECRET_KEY=sk_live_...

Now, modify your app.js to load these environment variables and structure your configuration.

// app.js (after)
const express = require('express');
const dotenv = require('dotenv');
const path = require('path');

const app = express();

// Load environment variables based on NODE_ENV
const envPath = path.resolve(__dirname, `.env.${process.env.NODE_ENV || 'development'}`);
dotenv.config({ path: envPath });

// Configuration object
const config = {
  env: process.env.NODE_ENV || 'development',
  port: parseInt(process.env.PORT, 10) || 3000,
  database: {
    host: process.env.DATABASE_HOST,
    port: parseInt(process.env.DATABASE_PORT, 10),
    user: process.env.DATABASE_USER,
    password: process.env.DATABASE_PASSWORD,
  },
  stripe: {
    secretKey: process.env.STRIPE_SECRET_KEY,
  },
};

// Example usage of configuration
console.log(`Running in ${config.env} mode.`);
console.log(`Database host: ${config.database.host}`);

// ... rest of app setup using config object ...

app.listen(config.port, () => {
  console.log(`Server running on port ${config.port}`);
});

To run in a specific environment, you’d set NODE_ENV before starting your app.

For development:

export NODE_ENV=development
node app.js

For production:

export NODE_ENV=production
node app.js

Or, often better, use a process manager like pm2:

ecosystem.config.js:

module.exports = {
  apps : [{
    name   : "my-express-app",
    script : "./app.js",
    env_production : {
      NODE_ENV : "production",
      PORT: 80
    },
    env_development : {
      NODE_ENV : "development",
      PORT: 3000
    }
  }]
}

Then start with:

pm2 start ecosystem.config.js --env production

This structured approach allows you to isolate configuration concerns. The config object becomes your single source of truth for application settings, making it easy to read, test, and modify. Each environment gets its own .env file, clearly defining its parameters without cluttering the core application logic.

The most common pitfall isn’t just forgetting to load the right .env file, but rather how dotenv interacts with existing environment variables. If NODE_ENV is already set in your shell before dotenv.config() runs, dotenv will respect that and load the corresponding .env file. However, if you then try to override it within the loaded .env file, it won’t take effect. The path option in dotenv.config() is crucial for explicitly telling it which file to load, bypassing shell-level precedence for the .env file itself.

The next hurdle is managing secrets that shouldn’t be in version control, like database passwords or API keys.

Want structured learning?

Take the full Express course →