Environment Variables & Config Management · learncode.live

The Twelve-Factor App Principle

Store config in the environment. Code should be the same across environments - only configuration changes.

Same code, different config:

Development:                    Production:
  DB_HOST=localhost               DB_HOST=prod-db.example.com
  DB_NAME=myapp_dev               DB_NAME=myapp
  LOG_LEVEL=debug                 LOG_LEVEL=info
  JWT_SECRET=dev-secret           JWT_SECRET=very-long-secure-key

Using dotenv

Installation

npm install dotenv

.env File (Never Commit!)

# .env
NODE_ENV=development
PORT=3000

# Database
MONGO_URI=mongodb://localhost:27017/myapp_dev
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
DB_USER=postgres
DB_PASSWORD=secret

# Auth
JWT_SECRET=my-dev-secret
JWT_EXPIRES_IN=24h
SESSION_SECRET=session-secret

# External Services
REDIS_URL=redis://localhost:6379
AWS_ACCESS_KEY=your-key
AWS_SECRET_KEY=your-secret
S3_BUCKET=myapp-dev-uploads

# API Keys
SENDGRID_API_KEY=sendgrid-key
STRIPE_SECRET_KEY=sk_test_...

# Logging
LOG_LEVEL=debug

Loading dotenv

// config/env.js - loaded first, before any other module
const path = require('path');

if (process.env.NODE_ENV !== 'production') {
  require('dotenv').config({
    path: path.join(__dirname, '..', `.env.${process.env.NODE_ENV || 'development'}`)
  });
}

index.js:

// Must be at the very top
require('./config/env');

const express = require('express');
// ... rest of the app

Environment-Specific Files

.env              # Base config (shared defaults)
.env.development  # Dev overrides
.env.staging      # Staging overrides
.env.production   # Production overrides
// Load precedence: .env.{NODE_ENV} overrides .env
const envFile = process.env.NODE_ENV || 'development';
require('dotenv').config({ path: `.env.${envFile}` });
require('dotenv').config({ path: '.env' }); // Fallback

Centralized Config Module

// config/index.js
require('dotenv').config();

const config = {
  env: process.env.NODE_ENV || 'development',

  server: {
    port: parseInt(process.env.PORT) || 3000,
    host: process.env.HOST || '0.0.0.0'
  },

  database: {
    mongoUri: process.env.MONGO_URI,
    host: process.env.DB_HOST || 'localhost',
    port: parseInt(process.env.DB_PORT) || 5432,
    name: process.env.DB_NAME || 'myapp',
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD
  },

  auth: {
    jwtSecret: process.env.JWT_SECRET,
    jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
    sessionSecret: process.env.SESSION_SECRET
  },

  redis: {
    url: process.env.REDIS_URL || 'redis://localhost:6379'
  },

  logging: {
    level: process.env.LOG_LEVEL || 'info'
  },

  services: {
    stripeKey: process.env.STRIPE_SECRET_KEY,
    sendgridKey: process.env.SENDGRID_API_KEY
  }
};

module.exports = config;

Config Validation

Validate required config on startup:

npm install joi
// config/validate.js
const Joi = require('joi');
const config = require('./index');

const schema = Joi.object({
  env: Joi.string().valid('development', 'staging', 'production').required(),

  server: Joi.object({
    port: Joi.number().default(3000),
    host: Joi.string().default('0.0.0.0')
  }),

  database: Joi.object({
    mongoUri: Joi.string().uri().required(),
    host: Joi.string(),
    port: Joi.number(),
    name: Joi.string(),
    user: Joi.string(),
    password: Joi.string()
  }),

  auth: Joi.object({
    jwtSecret: Joi.string().min(32).required().error(new Error('JWT_SECRET must be at least 32 characters')),
    jwtExpiresIn: Joi.string().default('24h'),
    sessionSecret: Joi.string().min(16).required()
  }),

  redis: Joi.object({
    url: Joi.string().uri()
  }),

  logging: Joi.object({
    level: Joi.string().valid('error', 'warn', 'info', 'debug').default('info')
  })
});

function validateConfig() {
  const { error, value } = schema.validate(config, { abortEarly: false });

  if (error) {
    console.error('❌ Invalid configuration:');
    error.details.forEach(detail => {
      console.error(`  - ${detail.message}`);
    });
    process.exit(1);
  }

  return value;
}

module.exports = { validateConfig };
// index.js - validate on startup
const { validateConfig } = require('./config/validate');
const config = validateConfig();

const express = require('express');
const app = express();

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

Using Config in the App

// routes/users.js
const config = require('../config');

router.get('/export', async (req, res) => {
  // Access config values
  const dbConfig = config.database;
  const users = await User.find();

  // Never expose secrets!
  res.json({
    count: users.length,
    environment: config.env,
    dbHost: dbConfig.host,  // OK - non-sensitive
    // Never: dbPassword: dbConfig.password
  });
});

.gitignore

# Environment files
.env
.env.*
!.env.example

# Config with secrets
config/secrets.json

.env.example (Commit This)

# Copy to .env and fill in values
NODE_ENV=development
PORT=3000
MONGO_URI=mongodb://localhost:27017/myapp
JWT_SECRET=change-me-to-a-long-random-string
SESSION_SECRET=change-me-too
REDIS_URL=redis://localhost:6379
LOG_LEVEL=debug

Production vs Development Config

// config/index.js - environment-specific overrides
const envSpecific = {
  development: {
    logging: { level: 'debug' },
    database: { name: 'myapp_dev' }
  },
  staging: {
    logging: { level: 'info' },
    database: { name: 'myapp_staging' }
  },
  production: {
    logging: { level: 'error' },
    database: { name: 'myapp' },
    server: { port: process.env.PORT || 8080 }
  }
};

const env = process.env.NODE_ENV || 'development';
const config = {
  ...baseConfig,
  ...envSpecific[env]
};

Secrets in Production

// Never hardcode secrets
// ❌ Bad
const JWT_SECRET = 'mySuperSecretKey12345';

// ✅ Good - environment variable
const JWT_SECRET = process.env.JWT_SECRET;

// ✅ Platform-specific secret management:
// AWS: AWS Secrets Manager / Parameter Store
// Heroku: Config Vars
// Docker: Docker secrets
// Kubernetes: Secrets
// HashiCorp: Vault

Key Takeaways

  • Store all config in environment variables (12-Factor App)
  • Use dotenv to load .env files in development
  • Create a centralized config module for consistent access
  • Validate required config on startup - fail fast
  • Keep a .env.example with dummy values committed to the repo
  • Add .env to .gitignore - never commit real secrets
  • Use platform-specific secret management in production
  • Only log non-sensitive config values
Courses