Build a Scalable Node.js API
Containerize and horizontally scale a Node.js REST API with Nginx.
Introduction
30 Second Summary
When you visit a popular website, your request doesn't hit a single server. It's distributed across multiple identical copies of the same application, each ready to handle traffic independently.
In this project, you will build a Product Catalog API with Node.js and Express.js, containerize it with Docker, and scale it to four instances behind an Nginx load balancer.
What You'll Build
You'll build a production-grade REST API that follows the same architecture patterns real companies use to handle traffic at scale.
By the end of this project, you'll have:
- A layered Express.js API with security hardening, structured logging, health checks, and graceful shutdown.
- A multi-stage Docker build that packages your API into an optimized production container.
- Four API replicas running behind an Nginx load balancer, proving horizontal scaling works.
- Secret Mission: Add filtering and search to your product catalog with query parameters.
Do I need any prior experience?
Basic JavaScript knowledge is all you need. You don't need prior experience with Docker, Nginx, or API design. This project walks you through every step.
Set Up Docker Desktop and Project
With Docker Desktop running and Node.js ready, it's time to scaffold the project that will become your production API.
In this step, you'll create the project folder, install the production packages the API depends on, and set up a layered folder structure that separates concerns cleanly.
In this step, get ready to:
- Create and initialize the project directory.
- Install all production dependencies with pinned versions.
- Create the layered folder structure for routes, controllers, services, and middleware.
Create the project directory
Every Node.js project starts with a dedicated folder and a package.json file that tracks metadata and dependencies.
- Switch back to your VS Code terminal and move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir product-catalog-api
cd product-catalog-api
- Initialize the project with a default package.json by running:
npm init -y
You should see output confirming a package.json file was created in your product-catalog-api folder.
Seeing a permission error?
Make sure you ran cd ~/Desktop first. If the Desktop path doesn't resolve, try cd $HOME/Desktop instead.
help me fix a permission error when creating my project folder
Install the production dependencies
Your API needs six packages that form the production middleware stack. Each handles a specific concern like security, logging, or performance.
- Install all six packages with exact version pins by running this command:
npm install --save-exact express@4.22.2 helmet@8.2.0 cors@2.8.6 morgan@1.11.0 express-rate-limit@8.5.2 compression@1.7.5
What are these packages?
- express is the web framework that handles HTTP requests and routing.
- helmet sets secure HTTP headers to protect against common web vulnerabilities.
- cors controls which domains can call your API.
- morgan logs every incoming request so you can monitor traffic.
- express-rate-limit prevents abuse by capping how many requests a single IP can make.
- compression shrinks response bodies so they travel faster over the network.
You should see output listing added packages with their versions. A node_modules folder and a package-lock.json file now exist in your project.
Install failed or timed out?
Check that you're inside the product-catalog-api folder by running pwd. The output should end with product-catalog-api.
If the install hangs, check your internet connection and try running the command again.
help me debug a failed npm install
Configure package.json and create the folder structure
The default package.json from npm init -y has placeholder values. You'll replace them with production-ready settings that point to your actual entry file and add useful scripts.
- Open package.json in VS Code by clicking it in the Explorer sidebar.
- Replace the entire contents of the file with this code:
{
"name": "product-catalog-api",
"version": "1.0.0",
"description": "Production-grade scalable Node.js API",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"compression": "1.7.5",
"cors": "2.8.6",
"express": "4.22.2",
"express-rate-limit": "8.5.2",
"helmet": "8.2.0",
"morgan": "1.11.0"
}
}
What does this configure?
- main tells Node.js which file to run when the app starts.
- start script runs the production server with npm start.
- dev script uses --watch to auto-restart the server whenever you save a file during development.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Now create the layered folder structure that separates your code by responsibility.
macOS
- Create all the project subdirectories by running this command in your terminal:
mkdir -p src/routes src/controllers src/services src/middleware
Windows
- Create all the project subdirectories by running this command in your terminal:
mkdir src/routes, src/controllers, src/services, src/middleware
What is a layered folder structure?
Each folder handles one job. routes defines URL paths. controllers handles request/response logic. services contains business logic. middleware holds shared processing functions.
This separation means each file has a single responsibility. When the project grows, you know exactly where to add new code.
- Confirm the folders were created by running:
ls src
You should see controllers, middleware, routes, and services listed in the output.
Folders not showing up?
Make sure your terminal is still inside the product-catalog-api directory. Run pwd to check your current location.
If you see an error about the src directory not existing, re-run the mkdir command from the correct tab above (macOS or Windows).
help me fix my folder structure creation
✔️ Awesome, I've got everything!
Great. Double check you've saved package.json and all four subdirectories appear under src/.
ⓧ I'd like to double check the full code
Here is the complete package.json you should have at this point:
{
"name": "product-catalog-api",
"version": "1.0.0",
"description": "Production-grade scalable Node.js API",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"compression": "1.7.5",
"cors": "2.8.6",
"express": "4.22.2",
"express-rate-limit": "8.5.2",
"helmet": "8.2.0",
"morgan": "1.11.0"
}
}
Your folder structure should look like this:
product-catalog-api/
├── node_modules/
├── package.json
├── package-lock.json
└── src/
├── routes/
├── controllers/
├── services/
└── middleware/
Your project skeleton is ready. Next up, you'll fill these folders with the Express app, routes, controllers, and services that form the actual API.
Build the Production API Structure
Your Docker environment and project scaffolding are ready. Now it's time to write the actual API code that will power your product catalog.
Production APIs follow a layered architecture that separates concerns into distinct modules. Each layer has one job and calls the layer below it. This makes the code easier to test, debug, and scale.
In this step, get ready to:
- Configure Express with production security and performance middleware.
- Implement product CRUD operations with a layered architecture.
- Set up a health check endpoint for load balancer integration.
Build the Express app with the production middleware pipeline
Every production API runs incoming requests through a stack of middleware before they reach your business logic. Middleware handles security headers, rate limiting, request parsing, and logging.
You'll build this pipeline in two files. First, the error handler that catches problems anywhere in the stack. Then, the main app file that wires everything together.
- Create the error handler module by pasting this code into src/middleware/errorHandler.js:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
function errorHandler(err, req, res, next) {
const statusCode = err.statusCode || 500;
const isOperational = err.isOperational || false;
// Log error with context
console.error(JSON.stringify({
event: 'error',
message: err.message,
statusCode,
isOperational,
path: req.path,
method: req.method,
ip: req.ip,
stack: process.env.NODE_ENV !== 'production' ? err.stack : undefined
}));
res.status(statusCode).json({
error: isOperational ? err.message : 'Internal server error',
...(process.env.NODE_ENV !== 'production' && { stack: err.stack })
});
}
module.exports = { AppError, errorHandler };
What does this code do?
The AppError class extends JavaScript's built-in Error with a statusCode and an isOperational flag. This lets you distinguish between expected errors (like "product not found") and unexpected crashes.
The errorHandler function is Express's global error catcher. It logs every error as structured JSON and returns a sanitized response. In production, stack traces are hidden from the client.
- Save src/middleware/errorHandler.js.
- You won't see a change yet. The main app file uses this module next.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
function errorHandler(err, req, res, next) {
const statusCode = err.statusCode || 500;
const isOperational = err.isOperational || false;
// Log error with context
console.error(JSON.stringify({
event: 'error',
message: err.message,
statusCode,
isOperational,
path: req.path,
method: req.method,
ip: req.ip,
stack: process.env.NODE_ENV !== 'production' ? err.stack : undefined
}));
res.status(statusCode).json({
error: isOperational ? err.message : 'Internal server error',
...(process.env.NODE_ENV !== 'production' && { stack: err.stack })
});
}
module.exports = { AppError, errorHandler };
- Now create the main Express app by pasting this code into src/app.js:
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const compression = require('compression');
const { rateLimit } = require('express-rate-limit');
const productRoutes = require('./routes/products');
const healthRoutes = require('./routes/health');
const { errorHandler } = require('./middleware/errorHandler');
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',')
: ['http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' }
});
app.use('/api', limiter);
// Request parsing and compression
app.use(express.json({ limit: '10kb' }));
app.use(compression());
// Logging
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Routes
app.use('/api/health', healthRoutes);
app.use('/api/products', productRoutes);
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Global error handler (must be last)
app.use(errorHandler);
module.exports = app;
What does this middleware pipeline do?
- helmet() adds security headers and removes the X-Powered-By header that tells attackers you're running Express.
- cors() controls which domains can call your API. It reads allowed origins from an environment variable or defaults to localhost.
- rateLimit caps each IP address at 100 requests per 15 minutes, preventing abuse.
- express.json({ limit: '10kb' }) parses incoming JSON bodies but rejects anything over 10kb to block oversized payloads.
- compression() shrinks response bodies with gzip before sending them to the client.
- morgan logs every HTTP request. In production it uses the verbose combined format. In development it uses the shorter dev format.
Order matters. The error handler MUST be the last middleware registered, or it won't catch errors from routes above it.
- Save src/app.js.
- You won't see a runtime change yet because the route files don't exist. You'll create them in the next substep.
Seeing a red squiggly line under a require path?
VS Code may flag require('./routes/products') because those files don't exist yet. This is expected and will resolve once you create the route files in the next substep.
Help me debug require path errors in my Express app
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const compression = require('compression');
const { rateLimit } = require('express-rate-limit');
const productRoutes = require('./routes/products');
const healthRoutes = require('./routes/health');
const { errorHandler } = require('./middleware/errorHandler');
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',')
: ['http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' }
});
app.use('/api', limiter);
// Request parsing and compression
app.use(express.json({ limit: '10kb' }));
app.use(compression());
// Logging
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Routes
app.use('/api/health', healthRoutes);
app.use('/api/products', productRoutes);
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Global error handler (must be last)
app.use(errorHandler);
module.exports = app;
Implement the product CRUD service and routes
A layered architecture separates your code into routes (URL mapping), controllers (request handling), and services (business logic). Each layer has one job and calls the layer below it.
You'll build from the bottom up. The service layer handles data operations, controllers handle request/response logic, and routes define URL paths.
- Create the product service by pasting this code into src/services/products.js:
const crypto = require('crypto');
const products = [
{
id: '1',
name: 'Wireless Headphones',
price: 79.99,
category: 'electronics',
description: 'Bluetooth over-ear headphones with noise cancellation',
createdAt: new Date().toISOString()
},
{
id: '2',
name: 'Running Shoes',
price: 129.99,
category: 'sports',
description: 'Lightweight running shoes with cushioned sole',
createdAt: new Date().toISOString()
}
];
exports.getAll = () => {
return products;
};
exports.getById = (id) => {
return products.find(p => p.id === id);
};
exports.create = ({ name, price, category, description }) => {
const product = {
id: crypto.randomUUID(),
name,
price: Number(price),
category: category || 'general',
description: description || '',
createdAt: new Date().toISOString()
};
products.push(product);
return product;
};
exports.update = (id, updates) => {
const index = products.findIndex(p => p.id === id);
if (index === -1) return null;
products[index] = {
...products[index],
...updates,
id: products[index].id,
createdAt: products[index].createdAt,
updatedAt: new Date().toISOString()
};
return products[index];
};
exports.remove = (id) => {
const index = products.findIndex(p => p.id === id);
if (index === -1) return false;
products.splice(index, 1);
return true;
};
What does this service do?
This is the data layer of your API. It stores products in an in-memory array (no database needed) and exposes five operations: list, find, create, update, and delete.
The crypto.randomUUID() call generates a unique ID for each new product. The two seed items give you test data from the moment the API starts.
- Save src/services/products.js.
- You won't see a change yet. The controller layer connects to this service next.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
const crypto = require('crypto');
const products = [
{
id: '1',
name: 'Wireless Headphones',
price: 79.99,
category: 'electronics',
description: 'Bluetooth over-ear headphones with noise cancellation',
createdAt: new Date().toISOString()
},
{
id: '2',
name: 'Running Shoes',
price: 129.99,
category: 'sports',
description: 'Lightweight running shoes with cushioned sole',
createdAt: new Date().toISOString()
}
];
exports.getAll = () => {
return products;
};
exports.getById = (id) => {
return products.find(p => p.id === id);
};
exports.create = ({ name, price, category, description }) => {
const product = {
id: crypto.randomUUID(),
name,
price: Number(price),
category: category || 'general',
description: description || '',
createdAt: new Date().toISOString()
};
products.push(product);
return product;
};
exports.update = (id, updates) => {
const index = products.findIndex(p => p.id === id);
if (index === -1) return null;
products[index] = {
...products[index],
...updates,
id: products[index].id,
createdAt: products[index].createdAt,
updatedAt: new Date().toISOString()
};
return products[index];
};
exports.remove = (id) => {
const index = products.findIndex(p => p.id === id);
if (index === -1) return false;
products.splice(index, 1);
return true;
};
- Create the product controller by pasting this code into src/controllers/products.js:
const productsService = require('../services/products');
const { AppError } = require('../middleware/errorHandler');
exports.getAll = (req, res) => {
const products = productsService.getAll();
res.json({ data: products, count: products.length });
};
exports.getById = (req, res, next) => {
const product = productsService.getById(req.params.id);
if (!product) {
return next(new AppError('Product not found', 404));
}
res.json({ data: product });
};
exports.create = (req, res, next) => {
const { name, price, category, description } = req.body;
if (!name || price === undefined) {
return next(new AppError('Name and price are required', 400));
}
const product = productsService.create({ name, price, category, description });
res.status(201).json({ data: product });
};
exports.update = (req, res, next) => {
const product = productsService.update(req.params.id, req.body);
if (!product) {
return next(new AppError('Product not found', 404));
}
res.json({ data: product });
};
exports.remove = (req, res, next) => {
const deleted = productsService.remove(req.params.id);
if (!deleted) {
return next(new AppError('Product not found', 404));
}
res.status(204).end();
};
What does a controller do?
Controllers sit between routes and services. They handle HTTP-specific concerns: extracting data from the request, calling the service layer, and formatting the response.
Notice how errors use next(new AppError(...)) instead of throwing directly. This passes the error to the global error handler you created earlier, keeping error formatting consistent across your entire API.
- Save src/controllers/products.js.
- You won't see a change yet. The route files wire this controller to URL paths next.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
const productsService = require('../services/products');
const { AppError } = require('../middleware/errorHandler');
exports.getAll = (req, res) => {
const products = productsService.getAll();
res.json({ data: products, count: products.length });
};
exports.getById = (req, res, next) => {
const product = productsService.getById(req.params.id);
if (!product) {
return next(new AppError('Product not found', 404));
}
res.json({ data: product });
};
exports.create = (req, res, next) => {
const { name, price, category, description } = req.body;
if (!name || price === undefined) {
return next(new AppError('Name and price are required', 400));
}
const product = productsService.create({ name, price, category, description });
res.status(201).json({ data: product });
};
exports.update = (req, res, next) => {
const product = productsService.update(req.params.id, req.body);
if (!product) {
return next(new AppError('Product not found', 404));
}
res.json({ data: product });
};
exports.remove = (req, res, next) => {
const deleted = productsService.remove(req.params.id);
if (!deleted) {
return next(new AppError('Product not found', 404));
}
res.status(204).end();
};
- Create the product routes by pasting this code into src/routes/products.js:
const express = require('express');
const router = express.Router();
const productsController = require('../controllers/products');
router.get('/', productsController.getAll);
router.get('/:id', productsController.getById);
router.post('/', productsController.create);
router.put('/:id', productsController.update);
router.delete('/:id', productsController.remove);
module.exports = router;
What does this route file do?
This is the thinnest layer. It maps HTTP methods and URL paths to the controller functions that handle them. Since app.js mounts this router at /api/products, a GET to / here actually responds to GET /api/products.
- Save src/routes/products.js.
- You won't see a change yet. One more route file to go.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const router = express.Router();
const productsController = require('../controllers/products');
router.get('/', productsController.getAll);
router.get('/:id', productsController.getById);
router.post('/', productsController.create);
router.put('/:id', productsController.update);
router.delete('/:id', productsController.remove);
module.exports = router;
Set up the health check endpoint
Load balancers need a lightweight endpoint to verify each instance is alive. The health check returns information like the container's hostname and uptime so you can prove different containers are responding.
- Create the health check route by pasting this code into src/routes/health.js:
const express = require('express');
const os = require('os');
const router = express.Router();
router.get('/', (req, res) => {
res.json({
status: 'healthy',
hostname: os.hostname(),
uptime: process.uptime(),
timestamp: new Date().toISOString(),
memoryUsage: process.memoryUsage().rss,
pid: process.pid
});
});
module.exports = router;
Why include a health check?
The /api/health route returns the container's hostname, uptime, and memory usage. When you scale to multiple instances later, this is how you'll prove different containers are responding to requests.
- Save src/routes/health.js.
- Verify that all modules resolve correctly by running this command:
node -e "require('./src/app')"
- If the command finishes with no output and returns you to the prompt, everything loaded successfully.
Seeing a MODULE_NOT_FOUND error?
Check the error message for the file path it couldn't find. Make sure your folder structure matches: src/routes/, src/controllers/, src/services/, and src/middleware/ should all exist with the correct files inside.
If you see a syntax error instead, double check for missing commas, brackets, or quotes in the file named in the error message. The output tells you the exact line number.
Help me debug this module loading error
✔️ Awesome, I've got everything!
Great. All your route and service files are wired together.
ⓧ I'd like to double check the full code
const express = require('express');
const os = require('os');
const router = express.Router();
router.get('/', (req, res) => {
res.json({
status: 'healthy',
hostname: os.hostname(),
uptime: process.uptime(),
timestamp: new Date().toISOString(),
memoryUsage: process.memoryUsage().rss,
pid: process.pid
});
});
module.exports = router;
Your API code is complete. Every layer is wired together: routes call controllers, controllers call services, and errors flow to the global handler. Next up, you'll add the server startup logic with graceful shutdown so your API can run and stop cleanly.
Add Production Resilience Patterns
Your API has routes, controllers, services, and middleware all wired up. But right now there's no way to actually start the server or shut it down safely.
In production, servers don't just crash and restart. They receive shutdown signals from orchestrators like Docker or Kubernetes and need to finish processing active requests before exiting. In this step, you'll create the server startup file with graceful shutdown, review how the error handling works, and understand the environment configuration pattern.
In this step, get ready to:
- Create the server startup file with graceful shutdown handling.
- Review the centralized error handling pattern already in place.
- Test the full API locally to confirm everything works together.
Implement graceful shutdown in server.js
When a container orchestrator wants to scale down or restart your app, it sends a SIGTERM signal. Your server needs to stop accepting new connections, finish handling in-flight requests, and then exit cleanly.
- Create a new file called src/server.js in VS Code.
- Add the server startup code by pasting the following:
const app = require('./app');
const os = require('os');
// Read port from environment or default to 3000
const PORT = process.env.PORT || 3000;
// Start the HTTP server
const server = app.listen(PORT, () => {
console.log(JSON.stringify({
event: 'server_started',
port: PORT,
hostname: os.hostname(),
nodeEnv: process.env.NODE_ENV || 'development',
pid: process.pid
}));
});
What does this code do?
This imports your Express app from app.js and starts it listening on a port. The port comes from the PORT environment variable (which Docker will set later) or defaults to 3000 for local development.
The startup log is structured JSON. This makes it easy for logging services to parse and search through your server events in production.
- Now add the graceful shutdown function directly below the server startup code:
// Graceful shutdown
function shutdown(signal) {
console.log(JSON.stringify({
event: 'shutdown_initiated',
signal,
hostname: os.hostname()
}));
server.close(() => {
console.log(JSON.stringify({
event: 'server_closed',
hostname: os.hostname()
}));
process.exit(0);
});
// Force exit after 30 seconds
setTimeout(() => {
console.error(JSON.stringify({
event: 'forced_shutdown',
reason: 'timeout',
hostname: os.hostname()
}));
process.exit(1);
}, 30000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
What does the shutdown function do?
- server.close() stops accepting new connections and waits for active requests to finish.
- The 30-second timeout is a safety net. If requests hang indefinitely, the server force-exits rather than becoming a zombie process.
- SIGTERM is the signal Docker sends when stopping a container. SIGINT is what you send when pressing Ctrl+C in the terminal.
- Add the unhandled error safety nets at the bottom of the file:
// Handle uncaught errors
process.on('unhandledRejection', (reason) => {
console.error(JSON.stringify({
event: 'unhandled_rejection',
reason: reason?.message || String(reason),
hostname: os.hostname()
}));
});
process.on('uncaughtException', (error) => {
console.error(JSON.stringify({
event: 'uncaught_exception',
message: error.message,
stack: error.stack,
hostname: os.hostname()
}));
process.exit(1);
});
Why catch unhandled errors at the process level?
These are your last line of defense. If a Promise rejects without a .catch() or an exception escapes all try/catch blocks, these handlers log the problem instead of crashing silently.
Uncaught exceptions force an exit because the process state is unreliable after one. Unhandled rejections are logged but the server keeps running since they're isolated to a single async operation.
- Save src/server.js.
- Start the server by running this command in your terminal:
node src/server.js
- You should see a JSON log line with "event":"server_started" and "port":3000 in your terminal.
- Press Ctrl+C to stop the server.
- You should see a "event":"shutdown_initiated" log followed by "event":"server_closed". That's the graceful shutdown in action.
Server won't start?
- If you see a "Cannot find module" error, make sure you ran the command from the product-catalog-api folder (not from inside src/).
- If port 3000 is already in use, check for other running Node processes or change the port with PORT=3001 node src/server.js.
Help me debug my server startup error.
Understand the centralized error handling
Your API already has a centralized error handling system built into src/middleware/errorHandler.js. This is a production pattern worth understanding.
- Open src/middleware/errorHandler.js in VS Code to review it.
How does centralized error handling work?
- AppError is a custom error class that carries a statusCode and an isOperational flag. Operational errors (like "Product not found") are safe to show to users.
- The errorHandler middleware is registered last in app.js. Any error passed to next() flows here.
- In production, stack traces are hidden from the response. Non-operational errors return a generic "Internal server error" message so internal details are never leaked.
Why centralize error handling?
Without it, every route handler needs its own try/catch with duplicated error formatting logic. Centralized handling means controllers just call next(new AppError(...)) and the middleware takes care of logging, formatting, and responding consistently.
Add environment configuration and test the API
Production apps read configuration from environment variables instead of hardcoding values. Your API already uses this pattern across two files.
Where are environment variables used?
- PORT in server.js controls which port the server listens on. Defaults to 3000.
- NODE_ENV in app.js and server.js toggles between dev-friendly and production-safe behavior (logging format, stack traces in errors).
- CORS_ORIGINS in app.js controls which domains can call your API. Defaults to localhost.
Each variable has a safe default so the API works locally without any configuration. Docker will override these values when you containerize the app later.
- Start the server again by running:
node src/server.js
- Open a second terminal in VS Code by pressing Ctrl+Shift+` (macOS and Windows).
- Test the health endpoint by running:
curl http://localhost:3000/api/health
- You should see a JSON response with "status":"healthy", a hostname, uptime, and process ID.
- Test the products endpoint by running:
curl http://localhost:3000/api/products
- You should see a JSON response with two products (Wireless Headphones and Running Shoes) and a count of 2.
- Test the error handling by requesting a product that doesn't exist:
curl http://localhost:3000/api/products/999
- You should see {"error":"Product not found"} with a stack trace (visible because you're in development mode).
- Switch back to the first terminal and press Ctrl+C to stop the server.
Getting "connection refused" on curl?
- Make sure the server is still running in your first terminal. Look for the server_started log.
- If you closed it accidentally, run node src/server.js again in the first terminal.
Help me figure out why my API isn't responding to curl requests.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/server.js and the server starts without errors.
ⓧ I'd like to double check the full code
Here's the complete src/server.js file:
const app = require('./app');
const os = require('os');
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
console.log(JSON.stringify({
event: 'server_started',
port: PORT,
hostname: os.hostname(),
nodeEnv: process.env.NODE_ENV || 'development',
pid: process.pid
}));
});
// Graceful shutdown
function shutdown(signal) {
console.log(JSON.stringify({
event: 'shutdown_initiated',
signal,
hostname: os.hostname()
}));
server.close(() => {
console.log(JSON.stringify({
event: 'server_closed',
hostname: os.hostname()
}));
process.exit(0);
});
// Force exit after 30 seconds
setTimeout(() => {
console.error(JSON.stringify({
event: 'forced_shutdown',
reason: 'timeout',
hostname: os.hostname()
}));
process.exit(1);
}, 30000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Handle uncaught errors
process.on('unhandledRejection', (reason) => {
console.error(JSON.stringify({
event: 'unhandled_rejection',
reason: reason?.message || String(reason),
hostname: os.hostname()
}));
});
process.on('uncaughtException', (error) => {
console.error(JSON.stringify({
event: 'uncaught_exception',
message: error.message,
stack: error.stack,
hostname: os.hostname()
}));
process.exit(1);
});
Your API is production-ready with graceful shutdown, centralized error handling, and environment-driven configuration. Next up, you'll package all of this into a Docker container so it can run identically anywhere.
Containerize with Docker
Your API has all the production patterns in place: security hardening, structured logging, health checks, and graceful shutdown. Now it's time to package everything into a portable container that runs identically on any machine.
Docker lets you freeze your entire application and its dependencies into a single image. In this step, you'll write a Dockerfile with a multi-stage build that produces a small, secure, production-optimized container.
In this step, get ready to:
- Create a Dockerfile using a multi-stage build for optimized images.
- Create a .dockerignore file to exclude unnecessary files.
- Build and test your container locally.
Create the Dockerfile with a multi-stage build
A multi-stage build separates the dependency installation from the final production image. The first stage installs packages, and the second stage copies only what's needed into a slim base image.
- Create a file called Dockerfile in the root of your product-catalog-api folder (no file extension).
- Paste the following code into it:
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Stage 2: Production image
FROM node:22-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
ENV NODE_ENV=production
ENV PORT=3000
USER appuser
EXPOSE 3000
CMD ["node", "src/server.js"]
What does this Dockerfile do?
The build happens in two stages to keep the final image as small as possible.
- Stage 1 (deps): starts from a lightweight node:22-alpine base, copies only package.json and package-lock.json, then installs production dependencies with npm ci --omit=dev.
- Stage 2: starts fresh from node:22-alpine again, creates a non-root user (appuser) for security, and copies only the installed node_modules, package.json, and src/ folder from the first stage.
- Running as a non-root user means that even if an attacker exploits your app, they cannot modify system files inside the container.
- Save Dockerfile.
Unsure about the file name?
The file must be named exactly Dockerfile with a capital D and no extension. If you accidentally add .txt or use a lowercase d, Docker will not find it.
help me fix my Dockerfile naming
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Stage 2: Production image
FROM node:22-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
ENV NODE_ENV=production
ENV PORT=3000
USER appuser
EXPOSE 3000
CMD ["node", "src/server.js"]
Create a .dockerignore file
A .dockerignore file tells Docker which files to skip when copying your project into the image. This keeps the image small and prevents sensitive files from leaking into the container.
- Create a file called .dockerignore in the root of your product-catalog-api folder.
- Paste the following into it:
node_modules
.git
.env
*.md
.gitignore
Why exclude these files?
- node_modules is excluded because the Dockerfile installs its own clean set of production dependencies inside the image.
- .git and *.md are development-only files that add size without benefit.
- .env could contain secrets that must never be baked into an image.
- Save .dockerignore.
Build and test the container
With both files in place, you can now build a Docker image and run it to verify everything works inside the container.
- Build your Docker image by running this command:
docker build -t product-catalog-api .
What does this command do?
- docker build reads your Dockerfile and executes each stage.
- -t product-catalog-api tags the resulting image with a name so you can reference it later.
- . tells Docker to use the current directory as the build context.
The build takes 30-60 seconds the first time while it downloads the node:22-alpine base image.
- Confirm the image was created by running:
docker images
You should see product-catalog-api listed with a latest tag.
Build failed?
- Make sure Docker Desktop is running. You should see the Docker whale icon in your system tray.
- Check that Dockerfile (capital D, no extension) exists in the same folder where you ran the command.
- Verify that package-lock.json exists. If it's missing, run npm install first to generate it.
Help me debug my Docker build error
- Start a container from your new image by running:
docker run -p 3000:3000 product-catalog-api
You should see a JSON log line with "event":"server_started" printed in the terminal. The container is now running your API on port 3000.
- Open a second terminal window by pressing Cmd+T (macOS) or Ctrl+Shift+T (Windows) in your terminal app.
- Test the health endpoint by running:
curl http://localhost:3000/api/health
You should see a JSON response containing "status":"healthy" along with a hostname, uptime, and timestamp.
What's different about the hostname?
The hostname field now shows a random string like a3f8e2c1b4d9 instead of your computer's name. That's the container ID. Each container gets its own isolated hostname.
- Switch back to the first terminal (the one running the container).
- Press Ctrl+C to stop the container.
You should see the graceful shutdown logs ("event":"shutdown_initiated" followed by "event":"server_closed") as the container exits cleanly.
Curl shows connection refused?
- Make sure the container is still running in the first terminal. If it crashed, check the error output.
- Verify you used -p 3000:3000 when starting the container. Without this flag, the port is not exposed to your host machine.
- If port 3000 is already in use, stop any other processes using it (like a local npm start from earlier testing).
Help me troubleshoot my Docker container connection
Your API is now packaged in a production-ready Docker image. Next up, you'll scale to multiple containers behind a load balancer to handle real traffic.
Scale with Docker Compose and Nginx
Your API is containerized and ready to run anywhere. But in production, a single container handling all traffic is a single point of failure.
The solution is horizontal scaling: running multiple copies of your API behind a load balancer that distributes incoming requests. In this step, you'll use Nginx as a reverse proxy and Docker Compose to orchestrate 4 replicas of your API.
In this step, get ready to:
- Create an Nginx configuration file for load balancing.
- Write a Docker Compose file to run 4 API replicas.
- Launch the stack and prove horizontal scaling works.
Create the Nginx load balancer configuration
Nginx sits in front of your API containers and decides which one handles each incoming request. You'll configure it to use a "least connections" strategy, sending each new request to whichever container is least busy.
- Create a new file called nginx.conf in the root of your product-catalog-api folder (the same level as your Dockerfile).
- Paste the following configuration into nginx.conf:
events {
worker_connections 1024;
}
http {
upstream api {
least_conn;
server app:3000;
}
server {
listen 80;
location / {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
What does this configuration do?
The upstream api block defines a pool of backend servers. The name app resolves to all container IPs behind the Docker Compose service named app.
The least_conn directive sends each new request to whichever container has the fewest active connections. The proxy_set_header lines forward the real client IP and protocol info so your API knows who made the request.
- Save nginx.conf.
Seeing a syntax error from Nginx later?
Make sure your curly braces match. Every opening { needs a closing }. Check that server app:3000; ends with a semicolon.
Help me fix an Nginx configuration syntax error in my Docker Compose project.
Create the Docker Compose file
Docker Compose lets you define multiple services (your API and Nginx) in one file and spin them all up together. You'll configure it to run 4 copies of your API behind the Nginx load balancer.
- Create a new file called docker-compose.yml in the root of your product-catalog-api folder.
- Paste the following into docker-compose.yml:
services:
app:
build: .
environment:
- PORT=3000
- NODE_ENV=production
deploy:
replicas: 4
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
depends_on:
- app
What does this configuration do?
- The app service builds your Dockerfile and deploys 4 replicas. Docker's internal DNS resolves the service name app to all 4 container IPs.
- The nginx service uses the official nginx:alpine image and maps port 80 on your machine to port 80 in the container.
- The volumes line mounts your local nginx.conf into the container so Nginx uses your load balancing configuration.
- The depends_on ensures the API containers start before Nginx tries to connect to them.
- Save docker-compose.yml.
YAML indentation issues?
YAML is whitespace-sensitive. Make sure you use spaces (not tabs) for indentation. Each level is indented by 2 spaces. The deploy key should be at the same level as build and environment under the app service.
Help me debug a YAML indentation error in my docker-compose.yml.
✔️ Awesome, I've got everything!
Great. Double check you've saved both nginx.conf and docker-compose.yml.
ⓧ I'd like to double check the full code
Here is the complete nginx.conf:
events {
worker_connections 1024;
}
http {
upstream api {
least_conn;
server app:3000;
}
server {
listen 80;
location / {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
Here is the complete docker-compose.yml:
services:
app:
build: .
environment:
- PORT=3000
- NODE_ENV=production
deploy:
replicas: 4
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
depends_on:
- app
Launch and test horizontal scaling
Time to bring your whole stack online. Docker Compose will build your API image, spin up 4 containers, and start Nginx in front of them.
- Launch the entire stack by running this command:
docker compose up --build
What does this command do?
The --build flag rebuilds your Docker image before starting the containers. Docker Compose then starts 4 instances of your API and 1 Nginx container. You'll see logs from all 5 containers streaming in the terminal.
You should see logs from multiple containers appearing in your terminal. Each container reports its hostname when it starts.
- Open a second terminal window.
- Test that the load balancer is distributing requests by running this command multiple times:
curl http://localhost/api/health
Run this command 4-5 times. Each response includes a hostname field. You should see different container hostnames appearing in the responses, proving that Nginx is distributing traffic across your 4 replicas.
Why are the hostnames different?
Each Docker container gets a unique hostname (a short hex string). Your health endpoint returns os.hostname(), so seeing different hostnames proves that different containers are handling your requests. This is horizontal scaling in action.
Getting connection refused on port 80?
Make sure Docker Desktop is running. Check that no other application is using port 80 on your machine. If you previously ran the container on port 3000, make sure it's stopped.
If you see Nginx errors in the Docker Compose logs about failing to connect to the upstream, wait a few seconds for the API containers to finish starting.
Help me troubleshoot why my Docker Compose stack isn't responding on port 80.
- Once you've confirmed different hostnames are appearing, bring the stack down by pressing Ctrl+C in the terminal running Docker Compose.
- Then clean up the containers by running:
docker compose down
What does docker compose down do?
This stops and removes all containers, networks, and volumes created by docker compose up. Your images remain cached, so the next time you run docker compose up --build it will start quickly.
You've just built a horizontally-scaled system. Your API runs on 4 containers with Nginx automatically distributing traffic between them. This is the exact pattern companies use to handle millions of requests in production.
Secret mission
Add Filtering and Search
Your product catalog API works great for listing all products. But what happens when a client needs to find electronics under $100, or search for headphones? Real APIs let consumers filter and search data through query parameters. Time to add that capability and prove it works across all 4 scaled instances.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker, so there are no ongoing costs.
Resources you used:
- Docker containers (4 API replicas + Nginx load balancer).
- Docker images (product-catalog-api and nginx:alpine).
- Local project folder (product-catalog-api on your Desktop).
- Docker Desktop (installed in Step 1).
Keep everything running
No action needed. Choose this if you are still actively building or testing your API.
- Your Docker Compose stack continues serving requests on port 80.
- Docker Desktop remains installed and available for future projects.
- Your product-catalog-api folder stays on your Desktop.
Pause - I'll come back to this later
Shut down running containers to free up system memory. Your images and project files stay on disk for a quick restart later.
- Stop all running containers by running this command from the product-catalog-api folder:
docker compose down
- Your Docker images remain cached locally. When you are ready to resume, run docker compose up --build to spin everything back up.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- Stop all containers and remove the Docker images by running this command from the product-catalog-api folder:
docker compose down --rmi all
- Delete the project folder. On macOS or Linux:
cd ~/Desktop
rm -rf product-catalog-api
- On Windows (PowerShell):
cd ~\Desktop
Remove-Item -Recurse -Force product-catalog-api
- If you do not plan to use Docker again, uninstall Docker Desktop. On Windows, open Settings > Apps > Apps & features, select Docker Desktop, then click Uninstall. On macOS, drag the Docker app from Applications to the Trash.
Nice Work!
Nice Work!
Nice work! You just built, containerized, and horizontally scaled a production-grade Node.js microservice from scratch.
You've learned how to:
- Build a production-structured Express.js REST API with layered architecture (routes, controllers, services) and a full middleware stack including Helmet security headers, CORS, rate limiting, compression, and structured logging.
- Containerize an API with a multi-stage Docker build optimized for production, running as a non-root user inside a slim Alpine image.
- Scale to 4 replicas behind an Nginx load balancer using Docker Compose, proving horizontal scaling by observing different container hostnames responding to requests.
- Secret Mission: Added query-parameter-based filtering and search to the product catalog, then rebuilt and redeployed the containers to verify it works at scale.
Ready to quiz yourself?