Episode 3 — NodeJS MongoDB Backend Architecture / 3.13 — Production Project Structure

3.13.c --- Configuration Management

In one sentence: A production application never hardcodes a database URL, a secret key, or a port number --- configuration is externalised into environment variables, validated at startup, and centralised through a config module so every file reads from one source of truth.

Navigation: <- 3.13.b File Naming & Git Configuration | 3.13.d --- Production Environment ->


1. package.json roles

package.json is not just a dependency list --- it is the manifest for your entire project.

Key sections

{
  "name": "blog-api",
  "version": "1.0.0",
  "description": "RESTful blog API with authentication",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "test": "jest --coverage",
    "test:watch": "jest --watchAll",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "prepare": "husky"
  },
  "keywords": ["blog", "api", "express", "mongodb"],
  "author": "Your Name",
  "license": "MIT",
  "dependencies": {
    "bcryptjs": "^2.4.3",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "helmet": "^7.1.0",
    "jsonwebtoken": "^9.0.2",
    "mongoose": "^8.0.0",
    "morgan": "^1.10.0"
  },
  "devDependencies": {
    "eslint": "^8.56.0",
    "husky": "^9.0.0",
    "jest": "^29.7.0",
    "lint-staged": "^15.2.0",
    "nodemon": "^3.0.2",
    "prettier": "^3.2.0",
    "supertest": "^6.3.3"
  }
}

Essential npm scripts explained

ScriptCommandPurpose
startnode server.jsProduction startup (used by hosting platforms)
devnodemon server.jsDevelopment with auto-reload on file changes
testjest --coverageRun test suite with coverage report
test:watchjest --watchAllRe-run tests on file changes (development)
linteslint .Check for code quality issues
lint:fixeslint . --fixAuto-fix linting issues where possible
formatprettier --write .Auto-format all files
format:checkprettier --check .Check formatting without modifying (CI use)
preparehuskyInstall Git hooks after npm install

dependencies vs devDependencies

CategoryWhat goes hereDeployed to production?
dependenciesExpress, Mongoose, bcrypt, JWTYes
devDependenciesESLint, Prettier, Jest, nodemonNo (npm install --production skips them)

Rule: If the app cannot run without it, it is a dependency. If it is only needed for development, testing, or building, it is a devDependency.


2. Environment variables with .env files

Environment variables let you change application behaviour without modifying code.

Why environment variables?

// WRONG: hardcoded values
mongoose.connect('mongodb://localhost:27017/mydb');
const secret = 'my-super-secret-key-123';
app.listen(3000);

// RIGHT: environment-driven
mongoose.connect(process.env.MONGODB_URI);
const secret = process.env.JWT_SECRET;
app.listen(process.env.PORT);
Problem with hardcodingHow env vars solve it
Secrets visible in source codeSecrets stay in .env (not committed)
Different values for dev/staging/prodEach environment has its own .env
Changing config requires code change + deployChange the variable, restart the process
Cannot share code publicly.env is in .gitignore, code is safe

3. The dotenv package

Node.js does not read .env files automatically. The dotenv package loads them into process.env.

Installation

npm install dotenv

Usage (load as early as possible)

// server.js (first line)
require('dotenv').config();

// Now process.env.PORT, process.env.MONGODB_URI, etc. are available
console.log(process.env.PORT); // "3000"

How it works

  1. dotenv reads the .env file from the project root
  2. Parses each KEY=VALUE line
  3. Sets process.env.KEY = VALUE
  4. Does not override existing environment variables (system env vars take priority)

4. .env file structure

# .env --- Local development configuration
# NEVER commit this file!

# Server
PORT=3000
NODE_ENV=development

# Database
MONGODB_URI=mongodb://localhost:27017/blog-api

# Authentication
JWT_SECRET=your-super-secret-key-change-in-production
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=another-secret-for-refresh-tokens
JWT_REFRESH_EXPIRES_IN=30d

# CORS
CORS_ORIGIN=http://localhost:5173

# Email (for password reset, verification)
SMTP_HOST=smtp.mailtrap.io
SMTP_PORT=587
SMTP_USER=your-mailtrap-user
SMTP_PASS=your-mailtrap-pass

# File uploads
MAX_FILE_SIZE=5242880
UPLOAD_DIR=./public/uploads

# Rate limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100

# Logging
LOG_LEVEL=debug

Rules for .env files

RuleWhy
No quotes around values (usually)dotenv handles it, but quotes can cause subtle bugs
No spaces around =PORT = 3000 may not parse correctly
Comments start with #For documentation within the file
All values are stringsPORT=3000 gives you "3000", not 3000 --- cast in your config module

5. .env.example --- Template for the team

.env.example is committed to the repository. It contains every required variable with placeholder values, but no real secrets.

# .env.example --- Copy this to .env and fill in real values
# cp .env.example .env

# Server
PORT=3000
NODE_ENV=development

# Database
MONGODB_URI=mongodb://localhost:27017/your-db-name

# Authentication
JWT_SECRET=replace-with-a-strong-random-string
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=replace-with-another-strong-random-string
JWT_REFRESH_EXPIRES_IN=30d

# CORS
CORS_ORIGIN=http://localhost:5173

# Email
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-smtp-user
SMTP_PASS=your-smtp-password

# File uploads
MAX_FILE_SIZE=5242880
UPLOAD_DIR=./public/uploads

# Rate limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100

# Logging
LOG_LEVEL=debug

Onboarding workflow

# New developer joins the team:
git clone git@github.com:team/blog-api.git
cd blog-api
cp .env.example .env        # Create local .env from template
# Edit .env with real values (get secrets from team lead / vault)
npm install
npm run dev

6. Never commit .env!

This is worth emphasising: your .gitignore must contain .env before you create the file.

# .gitignore
.env
.env.local
.env.*.local

If you accidentally committed .env:

# Remove .env from Git tracking (keeps the local file)
git rm --cached .env
echo ".env" >> .gitignore
git add .gitignore
git commit -m "chore: remove .env from tracking and add to .gitignore"

Warning: Even after removing .env from tracking, the secrets are still in your Git history. If real secrets were exposed, rotate them immediately (change passwords, regenerate keys).


7. process.env.NODE_ENV

NODE_ENV is the most important environment variable. It controls how your application behaves.

ValueMeaningTypical behaviour
developmentLocal developmentVerbose logging, detailed errors, CORS wide open
productionLive deploymentMinimal logging, generic errors, CORS restricted, compression
testTest suite runningIn-memory database, no logging, deterministic seeds

Using NODE_ENV in code

// Different error detail based on environment
if (process.env.NODE_ENV === 'development') {
  // Send full error stack to client (helpful during development)
  res.status(err.statusCode).json({
    status: 'error',
    message: err.message,
    stack: err.stack,
  });
} else {
  // Production: never expose internal details
  res.status(err.statusCode).json({
    status: 'error',
    message: err.isOperational ? err.message : 'Something went wrong',
  });
}
// Conditional middleware
if (process.env.NODE_ENV === 'development') {
  app.use(morgan('dev'));   // Coloured concise logs
} else {
  app.use(morgan('combined'));  // Apache-style logs for production
}

8. Config module pattern: centralized config/index.js

Instead of scattering process.env.SOMETHING across your codebase, centralise all config in one module.

// src/config/index.js
const dotenv = require('dotenv');
const path = require('path');

// Load .env file
dotenv.config({ path: path.resolve(__dirname, '../../.env') });

const config = {
  // Server
  env: process.env.NODE_ENV || 'development',
  port: parseInt(process.env.PORT, 10) || 3000,

  // Database
  mongoUri: process.env.MONGODB_URI || 'mongodb://localhost:27017/default-db',

  // JWT
  jwt: {
    secret: process.env.JWT_SECRET,
    expiresIn: process.env.JWT_EXPIRES_IN || '7d',
    refreshSecret: process.env.JWT_REFRESH_SECRET,
    refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
  },

  // CORS
  cors: {
    origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
  },

  // Email
  email: {
    host: process.env.SMTP_HOST,
    port: parseInt(process.env.SMTP_PORT, 10) || 587,
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },

  // Upload
  upload: {
    maxFileSize: parseInt(process.env.MAX_FILE_SIZE, 10) || 5 * 1024 * 1024,
    dir: process.env.UPLOAD_DIR || './public/uploads',
  },

  // Rate limiting
  rateLimit: {
    windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000,
    max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100,
  },

  // Logging
  logLevel: process.env.LOG_LEVEL || 'info',
};

module.exports = config;

Benefits of centralised config

// WITHOUT config module (scattered process.env calls)
const port = parseInt(process.env.PORT, 10) || 3000;  // repeated everywhere
mongoose.connect(process.env.MONGODB_URI);             // raw string, no validation

// WITH config module (single source of truth)
const config = require('./config');
app.listen(config.port);
mongoose.connect(config.mongoUri);
BenefitExplanation
Single source of truthEvery file reads from config, not from process.env
Type castingStrings converted to numbers, booleans once
Default valuesDefined in one place, not scattered
Easy to mock in testsOverride config instead of setting env vars
DiscoverableOpen config/index.js to see every setting the app uses

9. Validating environment variables at startup (fail fast)

If a required environment variable is missing, the app should crash immediately with a clear error --- not silently run with undefined values and fail mysteriously later.

Manual validation

// src/config/index.js (add at the bottom)
const requiredEnvVars = [
  'MONGODB_URI',
  'JWT_SECRET',
  'JWT_REFRESH_SECRET',
];

const missing = requiredEnvVars.filter((key) => !process.env[key]);

if (missing.length > 0) {
  throw new Error(
    `Missing required environment variables: ${missing.join(', ')}\n` +
    'Copy .env.example to .env and fill in the values.'
  );
}

Using a validation library (Joi)

// src/config/index.js
const Joi = require('joi');

const envSchema = Joi.object({
  NODE_ENV: Joi.string()
    .valid('development', 'production', 'test')
    .default('development'),
  PORT: Joi.number().default(3000),
  MONGODB_URI: Joi.string().uri().required()
    .description('MongoDB connection string'),
  JWT_SECRET: Joi.string().min(32).required()
    .description('JWT signing secret (min 32 characters)'),
  JWT_EXPIRES_IN: Joi.string().default('7d'),
  CORS_ORIGIN: Joi.string().default('http://localhost:5173'),
})
  .unknown(); // Allow other env vars (PATH, HOME, etc.)

const { error, value: envVars } = envSchema.validate(process.env);

if (error) {
  throw new Error(`Config validation error: ${error.message}`);
}

const config = {
  env: envVars.NODE_ENV,
  port: envVars.PORT,
  mongoUri: envVars.MONGODB_URI,
  jwt: {
    secret: envVars.JWT_SECRET,
    expiresIn: envVars.JWT_EXPIRES_IN,
  },
  cors: {
    origin: envVars.CORS_ORIGIN,
  },
};

module.exports = config;

What "fail fast" looks like

$ npm start

Error: Config validation error: "JWT_SECRET" is required
    at Object.<anonymous> (/app/src/config/index.js:25:9)
    ...

# Developer immediately knows what to fix.
# Compare to: silent failure where JWT signing crashes 2 hours later
# on the first login attempt with "secretOrPrivateKey must have a value"

10. 12-Factor App methodology (relevant parts)

The 12-Factor App is a set of principles for building modern, deployable web applications. Several factors directly apply to configuration:

FactorPrincipleHow we apply it
III. ConfigStore config in environment variables.env + dotenv + config/index.js
IV. Backing servicesTreat databases, caches, email as attached resourcesConnection strings in env vars, swappable by changing a URL
V. Build, release, runStrictly separate build and run stagesnpm run build (if applicable) vs npm start
X. Dev/prod parityKeep development, staging, and production as similar as possibleSame Docker image, different env vars
XI. LogsTreat logs as event streamsWrite to stdout, let the platform (PM2, Docker) handle routing

The core insight

"The twelve-factor app stores config in environment variables."

Config that varies between deploys (credentials, resource handles, per-deploy values) goes in env vars. Config that does not vary (code structure, framework settings, route definitions) stays in code.

// VARIES between deploys --> environment variable
const dbUri = process.env.MONGODB_URI;

// DOES NOT vary between deploys --> code
const app = express();
app.use(express.json());

11. Key takeaways

  1. package.json scripts are the project's command API --- standardise start, dev, test, lint, format.
  2. Never hardcode database URLs, secrets, or port numbers --- use environment variables via dotenv.
  3. .env.example is committed (no secrets); .env is gitignored (real secrets).
  4. NODE_ENV controls behaviour: development (verbose), production (secure), test (isolated).
  5. Centralise config in src/config/index.js --- one file, type-cast values, default values, validation.
  6. Fail fast --- validate required env vars at startup so missing config is caught immediately, not at 3 AM in production.
  7. 12-Factor principles guide configuration: env vars for what varies, code for what does not.

Explain-It Challenge

Explain without notes:

  1. Why should dotenv.config() be called on the very first line of server.js?
  2. A teammate committed the .env file to GitHub. It contains the production database password. List every step you would take to fix this.
  3. What is the difference between a dependency and a devDependency? Give an example of each and explain what happens when you run npm install --production.

Navigation: <- 3.13.b File Naming & Git Configuration | 3.13.d --- Production Environment ->