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
| Script | Command | Purpose |
|---|---|---|
start | node server.js | Production startup (used by hosting platforms) |
dev | nodemon server.js | Development with auto-reload on file changes |
test | jest --coverage | Run test suite with coverage report |
test:watch | jest --watchAll | Re-run tests on file changes (development) |
lint | eslint . | Check for code quality issues |
lint:fix | eslint . --fix | Auto-fix linting issues where possible |
format | prettier --write . | Auto-format all files |
format:check | prettier --check . | Check formatting without modifying (CI use) |
prepare | husky | Install Git hooks after npm install |
dependencies vs devDependencies
| Category | What goes here | Deployed to production? |
|---|---|---|
dependencies | Express, Mongoose, bcrypt, JWT | Yes |
devDependencies | ESLint, Prettier, Jest, nodemon | No (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 hardcoding | How env vars solve it |
|---|---|
| Secrets visible in source code | Secrets stay in .env (not committed) |
| Different values for dev/staging/prod | Each environment has its own .env |
| Changing config requires code change + deploy | Change 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
dotenvreads the.envfile from the project root- Parses each
KEY=VALUEline - Sets
process.env.KEY = VALUE - 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
| Rule | Why |
|---|---|
| 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 strings | PORT=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.
| Value | Meaning | Typical behaviour |
|---|---|---|
development | Local development | Verbose logging, detailed errors, CORS wide open |
production | Live deployment | Minimal logging, generic errors, CORS restricted, compression |
test | Test suite running | In-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);
| Benefit | Explanation |
|---|---|
| Single source of truth | Every file reads from config, not from process.env |
| Type casting | Strings converted to numbers, booleans once |
| Default values | Defined in one place, not scattered |
| Easy to mock in tests | Override config instead of setting env vars |
| Discoverable | Open 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:
| Factor | Principle | How we apply it |
|---|---|---|
| III. Config | Store config in environment variables | .env + dotenv + config/index.js |
| IV. Backing services | Treat databases, caches, email as attached resources | Connection strings in env vars, swappable by changing a URL |
| V. Build, release, run | Strictly separate build and run stages | npm run build (if applicable) vs npm start |
| X. Dev/prod parity | Keep development, staging, and production as similar as possible | Same Docker image, different env vars |
| XI. Logs | Treat logs as event streams | Write 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
package.jsonscripts are the project's command API --- standardisestart,dev,test,lint,format.- Never hardcode database URLs, secrets, or port numbers --- use environment variables via
dotenv. .env.exampleis committed (no secrets);.envis gitignored (real secrets).NODE_ENVcontrols behaviour:development(verbose),production(secure),test(isolated).- Centralise config in
src/config/index.js--- one file, type-cast values, default values, validation. - Fail fast --- validate required env vars at startup so missing config is caught immediately, not at 3 AM in production.
- 12-Factor principles guide configuration: env vars for what varies, code for what does not.
Explain-It Challenge
Explain without notes:
- Why should
dotenv.config()be called on the very first line ofserver.js? - A teammate committed the
.envfile to GitHub. It contains the production database password. List every step you would take to fix this. - What is the difference between a
dependencyand adevDependency? Give an example of each and explain what happens when you runnpm install --production.
Navigation: <- 3.13.b File Naming & Git Configuration | 3.13.d --- Production Environment ->