Mobile App with Backend & CI/CD

Build a full-stack mobile app with a containerized backend and CI/CD pipeline.

Introduction

30 Second Summary

Think about how your favorite apps deliver instant notifications, sync data in real-time, and handle thousands of users without breaking a sweat. Behind every polished mobile experience is a backend powered by containers, message queues, and automated pipelines.

In this project, you will build a full-stack e-commerce notification system from scratch. You'll create a Node.js REST API, containerize it with Docker, connect a Flutter mobile app, and automate everything with a CI/CD pipeline.

What You'll Build

A full-stack e-commerce notification system with a containerized Node.js backend, real-time WebSocket updates, RabbitMQ message queues, Firebase push notifications, and a Flutter mobile app.

By the end of this project, you'll have:

  • A Node.js REST API with JWT authentication, CRUD endpoints, and real-time WebSocket events running inside Docker containers alongside MongoDB, Redis, Nginx, and RabbitMQ.
  • A Flutter mobile app built with Clean Architecture and the BLoC pattern that displays products, receives live updates, and push notifications.
  • A complete CI/CD pipeline on GitHub Actions that automatically tests, builds, and pushes Docker images.
  • Secret Mission: Implement a real-time price alert system where users set price thresholds and receive push notifications when prices drop below their target.

Are there any prerequisites?

This is a multi-phase curriculum project (40-60 hours). You'll need basic JavaScript/TypeScript knowledge, basic Dart/Flutter familiarity, and an understanding of Git fundamentals.

Before We Start

This project involves multiple tools working together. Your backend runs on Node.js. Your services run inside Docker containers. Your mobile app is built with Flutter.

Before writing any code, you need each of these tools installed and working on your machine. This step walks you through installing and verifying all three.

In this step, get ready to:
  • Install and verify Node.js 20 LTS.
  • Install and verify Docker Desktop with Docker Compose.
  • Install and verify the Flutter SDK.
Install Node.js 20 LTS

Node.js is the JavaScript runtime that powers your backend API. This project requires version 20 or higher because Express.js 5.x depends on Node.js 18+.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or cmd (Windows) and press Enter to open it.
  • Check your Node.js version by running this command:
node --version

Look at the output in your terminal.

✔️ I see version 20 or higher

You're all set. Your Node.js installation meets the project's requirements. Move on to the next substep.

ⓧ I see a version lower than 20

You have Node.js installed, but the version is too old for this project. You need version 20 or higher.

  • Visit the Node.js downloads page at https://nodejs.org.
  • Download the Node.js 20 LTS installer for your operating system.
  • Run the installer and follow the prompts (accept all defaults).
  • Close and reopen your terminal, then run node --version again to confirm you see v20 or higher.

Why not just use your existing version?

Express.js 5.x requires Node.js 18 or higher. Using an older version causes cryptic errors when installing dependencies. Node.js 20 LTS gives you the stability and compatibility this project needs.

ⓧ Command not found

Node.js is not installed on your machine yet.

  • Visit the Node.js downloads page at https://nodejs.org.
  • Download the Node.js 20 LTS installer for your operating system (Windows .msi, macOS .pkg, or Linux binary).
  • Run the installer and follow the prompts (accept all defaults).
  • Close and reopen your terminal, then run node --version again to confirm you see v20 or higher.

Still not found after installing?

On Windows, the installer adds Node.js to your PATH automatically, but you must close and reopen your terminal for the change to take effect. On macOS and Linux, if you used a version manager like nvm, make sure to run nvm use 20 after installing.

Help me fix my Node.js PATH issue

Install Docker Desktop

Docker Desktop bundles everything you need to run containers on your machine. It includes the docker CLI, Docker Compose, and a graphical interface for managing containers.

  • Visit the Docker Desktop download page at https://docs.docker.com/get-started/get-docker/.
  • Download the installer for your operating system.
  • Run the installer and follow the prompts.
  • Once installed, launch Docker Desktop and wait for it to finish starting (the whale icon in your system tray stops animating).

What is Docker Desktop?

Docker Desktop is an all-in-one application that lets you build, run, and manage containers locally. It includes Docker Compose, which you will use to orchestrate multiple services (MongoDB, Redis, RabbitMQ, Nginx) alongside your Node.js app.

  • Verify that both docker and docker compose are available by running these commands in your terminal:
docker --version
docker compose version

✔️ I see version numbers for both commands

Docker Desktop is installed and running correctly. Both docker and docker compose are ready to use.

ⓧ docker compose says command not found

If docker --version works but docker compose version does not, you likely have an older Docker installation that uses the hyphenated docker-compose binary instead of the modern plugin-based command.

  • Update Docker Desktop to the latest version. The modern docker compose command ships with all recent Docker Desktop releases.
  • After updating, close and reopen your terminal, then run docker compose version again.

ⓧ Both commands not found

Docker Desktop is not installed or not running.

  • Download Docker Desktop from https://docs.docker.com/get-started/get-docker/.
  • Run the installer and restart your computer if prompted.
  • Launch Docker Desktop from your applications.
  • Wait for the whale icon in your system tray to stop animating (this means Docker has finished starting).
  • Open a new terminal window and run docker --version and docker compose version again.

Docker Desktop won't start on Windows?

Docker Desktop requires WSL 2 or Hyper-V enabled on Windows. If you see an error about virtualization, open PowerShell as Administrator and run wsl --install then restart your computer.

Help me troubleshoot Docker Desktop on Windows

Install the Flutter SDK

Flutter is the framework you will use to build the mobile app. It compiles to native ARM code and provides hot reload for fast development.

  • Visit the Flutter installation page at https://docs.flutter.dev/install.
  • Follow the guided installation for your operating system. The recommended path is the VS Code quick-start which installs the SDK and adds it to your PATH automatically.
  • Once installed, close and reopen your terminal.
  • Verify your Flutter installation by running this command:
flutter doctor

✔️ Flutter doctor runs without critical errors

Your Flutter installation is working. You may see some warnings about Android toolchain or connected devices. Those are fine for now. As long as the Flutter SDK itself shows a green checkmark, you are ready.

ⓧ Command not found

Flutter is not on your PATH yet.

  • If you installed Flutter manually, add the Flutter SDK's bin directory to your system PATH. On Windows, this is typically %USERPROFILE%\develop\flutter\bin.
  • Close and reopen your terminal after updating PATH.
  • Run flutter doctor again to verify.

Still seeing command not found?

Make sure you added the bin subdirectory to PATH, not just the flutter folder. The full path should end in flutter\bin (Windows) or flutter/bin (macOS/Linux).

Help me add Flutter to my PATH

What about VS Code, Git, and GitHub?

This project also requires VS Code as your code editor, Git for version control, and a GitHub account for your repository and CI/CD pipeline. If you do not have these yet, install VS Code from https://code.visualstudio.com and Git from https://git-scm.com.

Create a free GitHub account at https://github.com if you do not have one.

Your development environment is ready. Next up, you will initialize your Node.js project and start building the REST API foundation with Express.js.

Build the Node.js REST API Foundation

Your development environment is ready. Now it is time to build the backbone of your e-commerce notification system: the REST API.

Every professional backend starts with a solid foundation. You need an HTTP server that handles requests, logs activity, authenticates users, and catches errors gracefully. In this step, you will set up an Express.js server with all of these pieces in place.

In this step, get ready to:
  • Initialize a Node.js project with Express.js 5 and ES modules.
  • Create the project folder structure and a health check endpoint.
  • Implement a middleware stack for logging, authentication, and error handling.
Initialize the project and install dependencies

Before writing any application code, you need a project folder with a package.json file to manage your dependencies and scripts.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ecommerce-backend
cd ecommerce-backend
  • Initialize a new Node.js project by running:
npm init -y

What does this command do?

The npm init -y command creates a package.json file with default values. The -y flag skips the interactive questionnaire and accepts all defaults instantly.

  • Confirm the file was created by running:
ls

You should see package.json listed in the output.

  • Install the three packages your project needs by running:
npm install express morgan jsonwebtoken

What are these packages?

Each package serves a specific role in your API:

  • express is the web framework that handles HTTP requests and routing.
  • morgan is an HTTP request logger that prints each incoming request to the terminal.
  • jsonwebtoken creates and verifies JWT tokens for user authentication.

You should see a success message listing the installed packages and a new node_modules folder appears in your project.

Seeing permission errors?

On Windows, make sure you are running your terminal as Administrator. On macOS/Linux, try prefixing the install command with sudo.

If npm cannot find a package, check your internet connection and try again.

help me fix npm install errors in my Node.js project

Now you need to configure the project to use ES modules instead of CommonJS. ES modules let you use import/export syntax, which is the modern JavaScript standard.

  • Open the ecommerce-backend folder in VS Code.
  • Open package.json and replace its entire contents with the following:
{
  "name": "ecommerce-backend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js"
  },
  "dependencies": {
    "express": "^5.0.0",
    "morgan": "^1.10.0",
    "jsonwebtoken": "^9.0.0"
  }
}

What changed in package.json?

The "type": "module" field tells Node.js to treat all .js files as ES modules. Without it, Node.js defaults to CommonJS and your import statements would fail.

The scripts section adds two commands: start runs the server in production, and dev uses nodemon to auto-restart on file changes during development.

  • Save package.json.
Create the folder structure and Express server

A well-organized project keeps routes, middleware, models, and config separated into their own folders. This makes it easy to find and modify code as the project grows.

  • Create all four project folders at once by running this command in your terminal:
mkdir -p src/routes src/middleware src/models src/config
  • Confirm the folders were created by running:
ls src

You should see config, middleware, models, and routes listed in the output.

Why this folder structure?

Separating concerns into folders is a standard pattern in Node.js APIs. Routes define your endpoints. Middleware handles cross-cutting logic like logging and auth. Models define your data shapes. Config holds connection strings and settings.

  • Create the main server file by creating a new file at src/index.js and adding the following code:
import express from 'express';
import morgan from 'morgan';
import { errorHandler } from './middleware/errorHandler.js';

// Create the Express application
const app = express();
const PORT = process.env.PORT || 3000;

// Parse incoming JSON request bodies
app.use(express.json());
// Log every HTTP request to the terminal
app.use(morgan('dev'));

// Health check endpoint for monitoring
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

// Catch-all error handler (must be last)
app.use(errorHandler);

// Start listening for requests
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

export default app;

What does this code do?

This file is the entry point for your entire API. It creates an Express app, attaches middleware, defines a health check route, and starts listening on port 3000.

  • express.json() parses JSON bodies from incoming POST/PUT requests so you can access req.body.
  • morgan('dev') logs each request with its method, URL, status code, and response time.
  • The /health endpoint returns a simple JSON response, useful for load balancers to check if your server is alive.
  • export default app makes the app available for testing later.
  • Save src/index.js. You cannot run it yet because the errorHandler import does not exist. You will create it next.
Implement the middleware stack

Middleware functions sit between the incoming request and your route handlers. They process requests before they reach your routes, or catch errors after.

  • Create the error handler by creating a new file at src/middleware/errorHandler.js and adding the following code:
// Centralized error handler - catches all unhandled errors
export const errorHandler = (err, req, res, next) => {
  const status = err.status || 500;
  res.status(status).json({ message: err.message || 'Internal Server Error' });
};

What does this code do?

Express recognizes error-handling middleware by its four parameters: err, req, res, next. When any route or middleware throws an error or calls next(err), Express skips to this function.

It responds with the error's status code (or 500 if none is set) and a JSON message. This prevents your server from crashing on unexpected errors.

  • Save src/middleware/errorHandler.js.
  • Create the authentication middleware by creating a new file at src/middleware/auth.js and adding the following code:
import jwt from 'jsonwebtoken';

// Verify JWT token from the Authorization header
export const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ message: 'No token provided' });
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (err) {
    res.status(401).json({ message: 'Invalid token' });
  }
};

What does this code do?

This middleware protects routes that require authentication. It extracts the JWT token from the Authorization header (expected format: Bearer <token>).

  • If no token is provided, it immediately returns a 401 response.
  • If the token is invalid or expired, the catch block returns a 401 response.
  • If the token is valid, it attaches the decoded user data to req.user and calls next() to continue to the route handler.
  • Save src/middleware/auth.js.
  • Test your server by running:
npm start

You should see Server running on port 3000 printed in your terminal.

  • Open a new terminal tab and test the health check endpoint by running:
curl http://localhost:3000/health

You should see {"status":"ok"} returned. In the first terminal, you will also see Morgan logging the request.

  • Press Ctrl+C in the first terminal to stop the server.

Server not starting?

If you see an error about Cannot find module, make sure you saved both src/index.js and src/middleware/errorHandler.js. The import path must include the .js extension.

If you see an error about type or require, make sure your package.json includes "type": "module".

help me fix my Express.js server startup error

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point:

{
  "name": "ecommerce-backend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js"
  },
  "dependencies": {
    "express": "^5.0.0",
    "morgan": "^1.10.0",
    "jsonwebtoken": "^9.0.0"
  }
}
import express from 'express';
import morgan from 'morgan';
import { errorHandler } from './middleware/errorHandler.js';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());
app.use(morgan('dev'));

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use(errorHandler);

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

export default app;
import jwt from 'jsonwebtoken';

export const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ message: 'No token provided' });
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (err) {
    res.status(401).json({ message: 'Invalid token' });
  }
};
export const errorHandler = (err, req, res, next) => {
  const status = err.status || 500;
  res.status(status).json({ message: err.message || 'Internal Server Error' });
};

Your API foundation is solid. You have a running Express server with structured folders, request logging, JWT-ready authentication, and centralized error handling. Next up, you will build the actual CRUD endpoints for users and products.

Implement RESTful CRUD Endpoints

Your Express server is running with a health check and middleware stack. Now it needs actual endpoints that clients can interact with.

In this step, you'll build two sets of routes. The first handles user registration and login with secure password hashing and JWT tokens. The second provides full CRUD operations for products, protected by the authentication middleware you created earlier.

In this step, get ready to:
  • Design and implement user auth routes with bcrypt password hashing and JWT tokens.
  • Build product CRUD routes protected by authentication middleware.
  • Add request validation and register all routes in the Express app.
Design and implement user auth routes

Authentication is the gateway to your API. Users need to register an account and log in to receive a JWT token. That token proves their identity on subsequent requests.

You'll use bcryptjs to hash passwords before storing them. This ensures passwords are never saved in plain text.

  • Install the bcryptjs package by running this command:
npm install bcryptjs

What is bcryptjs?

bcryptjs is a pure JavaScript implementation of the bcrypt hashing algorithm. It takes a plain-text password and produces a one-way hash that cannot be reversed.

When a user logs in, bcrypt compares their submitted password against the stored hash without ever decrypting it.

  • Confirm the install succeeded by running:
cat package.json

You should see bcryptjs listed under dependencies.

  • Create a new file at src/routes/auth.js by running:
touch src/routes/auth.js
  • Open src/routes/auth.js and add the imports, in-memory users array, and register route:
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';

const router = Router();

// Temporary in-memory store (replaced by MongoDB later)
const users = [];

// POST /api/auth/register - create a new user account
router.post('/register', async (req, res, next) => {
  try {
    const { name, email, password } = req.body;
    if (!name || !email || !password) return res.status(400).json({ message: 'All fields required' });
    const existing = users.find(u => u.email === email);
    if (existing) return res.status(409).json({ message: 'Email already registered' });
    const hashed = await bcrypt.hash(password, 10);
    const user = { id: Date.now().toString(), name, email, password: hashed, role: 'user' };
    users.push(user);
    const token = jwt.sign({ id: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1d' });
    res.status(201).json({ token });
  } catch (err) { next(err); }
});

What does this code do?

The register route extracts name, email, and password from the request body. It validates that all fields are present before continuing.

bcrypt.hash(password, 10) hashes the password with 10 salt rounds. The hashed result is stored instead of the plain-text password.

After creating the user, jwt.sign() generates a token containing the user's ID and role. The client sends this token on future requests to prove their identity.

  • Below the register route, add the login route and the default export:
// POST /api/auth/login - authenticate and return a token
router.post('/login', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    const user = users.find(u => u.email === email);
    if (!user) return res.status(401).json({ message: 'Invalid credentials' });
    const match = await bcrypt.compare(password, user.password);
    if (!match) return res.status(401).json({ message: 'Invalid credentials' });
    const token = jwt.sign({ id: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1d' });
    res.json({ token });
  } catch (err) { next(err); }
});

export default router;

What does the login route do?

The login route looks up the user by email. If no user exists, it returns a generic 401 error.

bcrypt.compare() checks the submitted password against the stored hash. Both failure cases return the same Invalid credentials message. This prevents attackers from knowing whether an email exists in the system.

  • Save src/routes/auth.js.

Seeing a syntax error?

Make sure you have the closing }); for both route handlers. Each router.post() call needs its own closing parenthesis and semicolon.

Also confirm your package.json has "type": "module" so that import statements work.

help me fix a syntax error in my auth routes

Implement product CRUD routes

Products are the core resource of your e-commerce API. You'll create endpoints for listing, reading, creating, updating, and deleting products.

The read endpoints (GET) are public. The write endpoints (POST, PUT, DELETE) require authentication using the authenticate middleware from the previous step.

  • Create a new file at src/routes/products.js by running:
touch src/routes/products.js
  • Open src/routes/products.js and add the imports, in-memory store, and read routes:
import { Router } from 'express';
import { authenticate } from '../middleware/auth.js';

const router = Router();
const products = [];

// GET /api/products - list all products (public)
router.get('/', (req, res) => {
  res.json(products);
});

// GET /api/products/:id - get a single product (public)
router.get('/:id', (req, res) => {
  const product = products.find(p => p.id === req.params.id);
  if (!product) return res.status(404).json({ message: 'Product not found' });
  res.json(product);
});

What does this code do?

The authenticate middleware is imported but not applied to the GET routes. This means anyone can browse products without logging in.

The /:id route uses Express route parameters. When a request hits /api/products/123, req.params.id equals 123. If no product matches, it returns a 404 status.

  • Below the GET routes, add the protected write routes and the export:
// POST /api/products - create a product (auth required)
router.post('/', authenticate, (req, res) => {
  const { name, description, price, category, stock } = req.body;
  if (!name || price === undefined) return res.status(400).json({ message: 'name and price required' });
  const product = { id: Date.now().toString(), name, description, price, category, stock };
  products.push(product);
  res.status(201).json(product);
});

// PUT /api/products/:id - update a product (auth required)
router.put('/:id', authenticate, (req, res) => {
  const idx = products.findIndex(p => p.id === req.params.id);
  if (idx === -1) return res.status(404).json({ message: 'Product not found' });
  products[idx] = { ...products[idx], ...req.body };
  res.json(products[idx]);
});

// DELETE /api/products/:id - remove a product (auth required)
router.delete('/:id', authenticate, (req, res) => {
  const idx = products.findIndex(p => p.id === req.params.id);
  if (idx === -1) return res.status(404).json({ message: 'Product not found' });
  products.splice(idx, 1);
  res.status(204).send();
});

export default router;

What does this code do?

Each write route passes authenticate as the second argument to router.post(), router.put(), and router.delete(). This means Express runs the JWT check before the route handler executes.

The POST route validates that name and price are present. PUT uses the spread operator to merge incoming fields with existing data so clients can send partial updates. DELETE returns 204 (No Content) because there's no body to send back.

  • Save src/routes/products.js.

Import path not resolving?

The import path '../middleware/auth.js' must include the .js extension. ES modules in Node.js do not resolve extensions automatically like CommonJS does.

help me fix my import paths in products.js

Register routes and verify the API

Your route files are ready, but Express doesn't know about them yet. You need to import and mount them on the app with app.use().

  • Open src/index.js. Find this line near the top:
import { errorHandler } from './middleware/errorHandler.js';
  • Add these two import lines directly below it:
import authRoutes from './routes/auth.js';
import productRoutes from './routes/products.js';
  • Next, find the health check route:
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});
  • Add these two route registrations directly below the health check, before app.use(errorHandler):
app.use('/api/auth', authRoutes);
app.use('/api/products', productRoutes);

Why does order matter here?

Express processes middleware in the order you register it. Your routes must come before the errorHandler middleware.

If a route handler calls next(err), Express passes the error down to the next error-handling middleware. That's why errorHandler sits last.

  • Save src/index.js and start the server by running:
JWT_SECRET=devSecret123 npm run dev
  • Test the register endpoint by opening a new terminal and running:
curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"name":"Test","email":"test@example.com","password":"pass123"}'

You should see a JSON response containing a token field. This confirms your register route, bcrypt hashing, and JWT generation all work.

  • Test the products endpoint by running:
curl http://localhost:3000/api/products

You should see an empty array [] because no products exist yet. The GET route is public, so no token is needed.

Getting Connection refused?

Make sure your server is still running in the other terminal. Check that you set the JWT_SECRET environment variable when starting the server. Without it, the JWT middleware throws an error.

If you see Cannot find module, double-check that all import paths include the .js extension.

help me debug my Express server startup errors

✔️ Awesome, I've got everything!

Great. Make sure you've saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/routes/auth.js:

import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';

const router = Router();

const users = [];

router.post('/register', async (req, res, next) => {
  try {
    const { name, email, password } = req.body;
    if (!name || !email || !password) return res.status(400).json({ message: 'All fields required' });
    const existing = users.find(u => u.email === email);
    if (existing) return res.status(409).json({ message: 'Email already registered' });
    const hashed = await bcrypt.hash(password, 10);
    const user = { id: Date.now().toString(), name, email, password: hashed, role: 'user' };
    users.push(user);
    const token = jwt.sign({ id: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1d' });
    res.status(201).json({ token });
  } catch (err) { next(err); }
});

router.post('/login', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    const user = users.find(u => u.email === email);
    if (!user) return res.status(401).json({ message: 'Invalid credentials' });
    const match = await bcrypt.compare(password, user.password);
    if (!match) return res.status(401).json({ message: 'Invalid credentials' });
    const token = jwt.sign({ id: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1d' });
    res.json({ token });
  } catch (err) { next(err); }
});

export default router;

Here is the complete src/routes/products.js:

import { Router } from 'express';
import { authenticate } from '../middleware/auth.js';

const router = Router();
const products = [];

router.get('/', (req, res) => {
  res.json(products);
});

router.get('/:id', (req, res) => {
  const product = products.find(p => p.id === req.params.id);
  if (!product) return res.status(404).json({ message: 'Product not found' });
  res.json(product);
});

router.post('/', authenticate, (req, res) => {
  const { name, description, price, category, stock } = req.body;
  if (!name || price === undefined) return res.status(400).json({ message: 'name and price required' });
  const product = { id: Date.now().toString(), name, description, price, category, stock };
  products.push(product);
  res.status(201).json(product);
});

router.put('/:id', authenticate, (req, res) => {
  const idx = products.findIndex(p => p.id === req.params.id);
  if (idx === -1) return res.status(404).json({ message: 'Product not found' });
  products[idx] = { ...products[idx], ...req.body };
  res.json(products[idx]);
});

router.delete('/:id', authenticate, (req, res) => {
  const idx = products.findIndex(p => p.id === req.params.id);
  if (idx === -1) return res.status(404).json({ message: 'Product not found' });
  products.splice(idx, 1);
  res.status(204).send();
});

export default router;

Here is the updated src/index.js:

import express from 'express';
import morgan from 'morgan';
import { errorHandler } from './middleware/errorHandler.js';
import authRoutes from './routes/auth.js';
import productRoutes from './routes/products.js';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());
app.use(morgan('dev'));

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use('/api/auth', authRoutes);
app.use('/api/products', productRoutes);

app.use(errorHandler);

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

export default app;

Your API now has a complete set of RESTful endpoints with authentication and validation. Next up, you'll connect these routes to a real MongoDB database so data persists between server restarts.

Connect MongoDB with Mongoose

Your API routes currently store data in plain JavaScript arrays. Every time the server restarts, all users and products vanish.

In this step, you'll connect a real database to your backend using Mongoose, an Object Data Modeling library for MongoDB. Mongoose lets you define schemas that enforce data structure, add indexes for fast queries, and interact with MongoDB through simple JavaScript methods.

In this step, get ready to:
  • Connect to MongoDB using Mongoose with environment variables.
  • Define User and Product Mongoose schemas with indexes.
  • Implement a repository pattern so routes use Mongoose models.
Connect to MongoDB with environment variables

Mongoose needs a connection string to locate your MongoDB instance. You'll store this string in a .env file so it stays out of your codebase.

  • Install Mongoose by running this command:
npm install mongoose

Install failing?

Make sure you're running the command from your project's root folder (where package.json lives). Check your internet connection and try again.

Help me troubleshoot my npm install error

  • Create a file called .env in your project root (next to package.json) and add the following environment variables:
PORT=3000
MONGODB_URI=mongodb://mongodb:27017/ecommerce
JWT_SECRET=supersecretkey

What do these variables do?

  • PORT sets which port your Express server listens on.
  • MONGODB_URI is the connection string Mongoose uses to find your MongoDB instance. The hostname mongodb matches the Docker service name you'll create later.
  • JWT_SECRET is the signing key for your authentication tokens.
  • Save .env.
  • Create the folder src/config and a new file inside it called database.js. Add the connection logic:
import mongoose from 'mongoose';

// Connect to MongoDB using the URI from environment variables
export const connectDB = async () => {
  await mongoose.connect(process.env.MONGODB_URI);
  console.log('MongoDB connected');
};

What does this code do?

The connectDB function calls mongoose.connect() with the connection string from your .env file. Mongoose handles connection pooling and reconnection automatically.

  • Save src/config/database.js.

Now wire the database connection into your server entry point.

  • Open src/index.js. Find this line near the top:
import { errorHandler } from './middleware/errorHandler.js';
  • Add this import directly above it:
import { connectDB } from './config/database.js';
  • Next, find this line:
app.use(express.json());
  • Add connectDB(); on the line directly above it, so the block looks like this:
connectDB();

app.use(express.json());

Why call connectDB before middleware?

Calling connectDB() early ensures Mongoose starts connecting as soon as the server boots. By the time a request hits a route handler, the connection is usually ready.

  • Save src/index.js.

You won't see a successful connection yet because MongoDB isn't running locally. You'll launch it via Docker Compose in a later step.

✔️ Awesome, I've got everything!

Great. Make sure you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is src/config/database.js:

import mongoose from 'mongoose';

export const connectDB = async () => {
  await mongoose.connect(process.env.MONGODB_URI);
  console.log('MongoDB connected');
};

Here is the updated src/index.js:

import express from 'express';
import morgan from 'morgan';
import { connectDB } from './config/database.js';
import { errorHandler } from './middleware/errorHandler.js';
import authRoutes from './routes/auth.js';
import productRoutes from './routes/products.js';

const app = express();
const PORT = process.env.PORT || 3000;

connectDB();

app.use(express.json());
app.use(morgan('dev'));

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use('/api/auth', authRoutes);
app.use('/api/products', productRoutes);

app.use(errorHandler);

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

export default app;
Design User and Product schemas

Mongoose schemas define the shape of your documents. They enforce which fields are allowed, what types they hold, and which fields get database indexes for faster lookups.

  • Create the folder src/models and add a new file called User.js inside it. Add the User schema:
import mongoose from 'mongoose';

// Define the User document shape and constraints
const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true, index: true },
  password: { type: String, required: true },
  role: { type: String, default: 'user' },
  fcmToken: { type: String }
}, { timestamps: true });

export default mongoose.model('User', userSchema);

What does this schema do?

  • required: true means Mongoose will reject any document missing that field.
  • unique: true on email creates a unique index so no two users can share the same email address.
  • index: true tells MongoDB to index the email field for fast lookups during login.
  • timestamps: true automatically adds createdAt and updatedAt fields to every document.
  • fcmToken will store each user's Firebase Cloud Messaging device token for push notifications later.
  • Save src/models/User.js.
  • Create another file in src/models called Product.js. Add the Product schema:
import mongoose from 'mongoose';

// Define the Product document shape and constraints
const productSchema = new mongoose.Schema({
  name: { type: String, required: true, index: true },
  description: String,
  price: { type: Number, required: true },
  category: String,
  stock: { type: Number, default: 0 }
}, { timestamps: true });

export default mongoose.model('Product', productSchema);

What does this schema do?

  • name has index: true so product searches by name are fast.
  • price is required because a product without a price can't be listed.
  • stock defaults to 0 so new products start with zero inventory.
  • Save src/models/Product.js.
  • Verify both files exist by running:
ls src/models

You should see Product.js and User.js in the output.

Don't see both files?

Make sure you created the src/models folder first. The filenames are case-sensitive: User.js and Product.js (capital U and P).

Help me find my missing model files

✔️ Awesome, I've got everything!

Great. Your schemas are ready. Make sure both files are saved.

ⓧ I'd like to double check the full code

Here is src/models/User.js:

import mongoose from 'mongoose';

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true, index: true },
  password: { type: String, required: true },
  role: { type: String, default: 'user' },
  fcmToken: { type: String }
}, { timestamps: true });

export default mongoose.model('User', userSchema);

Here is src/models/Product.js:

import mongoose from 'mongoose';

const productSchema = new mongoose.Schema({
  name: { type: String, required: true, index: true },
  description: String,
  price: { type: Number, required: true },
  category: String,
  stock: { type: Number, default: 0 }
}, { timestamps: true });

export default mongoose.model('Product', productSchema);
Implement the repository pattern

A repository is a thin layer between your route handlers and your database models. It keeps your routes clean by hiding Mongoose query details behind simple function calls.

Why use a repository pattern?

If you ever swap MongoDB for a different database, you only change the repository files. Your route handlers stay untouched because they call generic functions like findAll() and createProduct() instead of Mongoose-specific methods.

  • Create the folder src/repositories and add a file called userRepository.js inside it:
import User from '../models/User.js';

// User data access functions
export const findByEmail = (email) => User.findOne({ email });
export const createUser = (data) => User.create(data);
export const findById = (id) => User.findById(id);

What does this code do?

Each exported function wraps a single Mongoose query. findByEmail locates a user by email for login. createUser inserts a new document into the users collection.

  • Save src/repositories/userRepository.js.
  • Create another file in src/repositories called productRepository.js:
import Product from '../models/Product.js';

// Product data access functions
export const findAll = () => Product.find();
export const findById = (id) => Product.findById(id);
export const createProduct = (data) => Product.create(data);
export const updateProduct = (id, data) => Product.findByIdAndUpdate(id, data, { new: true });
export const deleteProduct = (id) => Product.findByIdAndDelete(id);

What does this code do?

  • findAll() returns every product in the collection.
  • findByIdAndUpdate with { new: true } returns the document after the update, not the old version.
  • findByIdAndDelete removes the document and returns it for confirmation.
  • Save src/repositories/productRepository.js.

Now update your route files to use the repositories instead of in-memory arrays.

  • Replace the entire contents of src/routes/auth.js with the following:
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { findByEmail, createUser } from '../repositories/userRepository.js';

const router = Router();

// Register a new user
router.post('/register', async (req, res, next) => {
  try {
    const { name, email, password } = req.body;
    if (!name || !email || !password) return res.status(400).json({ message: 'All fields required' });
    const existing = await findByEmail(email);
    if (existing) return res.status(409).json({ message: 'Email already registered' });
    const hashed = await bcrypt.hash(password, 10);
    const user = await createUser({ name, email, password: hashed });
    const token = jwt.sign({ id: user._id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1d' });
    res.status(201).json({ token });
  } catch (err) { next(err); }
});

// Log in an existing user
router.post('/login', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    const user = await findByEmail(email);
    if (!user) return res.status(401).json({ message: 'Invalid credentials' });
    const match = await bcrypt.compare(password, user.password);
    if (!match) return res.status(401).json({ message: 'Invalid credentials' });
    const token = jwt.sign({ id: user._id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1d' });
    res.json({ token });
  } catch (err) { next(err); }
});

export default router;

What changed in auth.js?

  • The in-memory users array is gone. All data access now goes through findByEmail and createUser from the repository.
  • The token payload uses user._id instead of user.id because MongoDB documents use _id as their identifier.
  • Save src/routes/auth.js.
  • Replace the entire contents of src/routes/products.js with the following:
import { Router } from 'express';
import { authenticate } from '../middleware/auth.js';
import { findAll, findById, createProduct, updateProduct, deleteProduct } from '../repositories/productRepository.js';

const router = Router();

// Get all products
router.get('/', async (req, res, next) => {
  try { res.json(await findAll()); } catch (err) { next(err); }
});

// Get a single product by ID
router.get('/:id', async (req, res, next) => {
  try {
    const product = await findById(req.params.id);
    if (!product) return res.status(404).json({ message: 'Product not found' });
    res.json(product);
  } catch (err) { next(err); }
});

// Create a new product (auth required)
router.post('/', authenticate, async (req, res, next) => {
  try {
    const product = await createProduct(req.body);
    res.status(201).json(product);
  } catch (err) { next(err); }
});

// Update a product by ID (auth required)
router.put('/:id', authenticate, async (req, res, next) => {
  try {
    const product = await updateProduct(req.params.id, req.body);
    if (!product) return res.status(404).json({ message: 'Product not found' });
    res.json(product);
  } catch (err) { next(err); }
});

// Delete a product by ID (auth required)
router.delete('/:id', authenticate, async (req, res, next) => {
  try {
    await deleteProduct(req.params.id);
    res.status(204).send();
  } catch (err) { next(err); }
});

export default router;

What changed in products.js?

The in-memory products array and all manual .find() / .splice() logic is gone. Each handler now delegates to one repository function. Errors propagate through next(err) to your centralized error handler.

  • Save src/routes/products.js.
  • Confirm your project structure by running:
ls src/repositories

You should see productRepository.js and userRepository.js in the output.

Seeing import errors in your editor?

Check that the import paths in your route files match the folder structure exactly. The path ../repositories/userRepository.js is relative to the src/routes/ folder. Make sure you included the .js extension (required for ES modules).

Help me fix my import path errors

✔️ Awesome, I've got everything!

Great. Double check that you've saved all four files before moving on.

ⓧ I'd like to double check the full code

Here is src/repositories/userRepository.js:

import User from '../models/User.js';

export const findByEmail = (email) => User.findOne({ email });
export const createUser = (data) => User.create(data);
export const findById = (id) => User.findById(id);

Here is src/repositories/productRepository.js:

import Product from '../models/Product.js';

export const findAll = () => Product.find();
export const findById = (id) => Product.findById(id);
export const createProduct = (data) => Product.create(data);
export const updateProduct = (id, data) => Product.findByIdAndUpdate(id, data, { new: true });
export const deleteProduct = (id) => Product.findByIdAndDelete(id);

Here is the updated src/routes/auth.js:

import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { findByEmail, createUser } from '../repositories/userRepository.js';

const router = Router();

router.post('/register', async (req, res, next) => {
  try {
    const { name, email, password } = req.body;
    if (!name || !email || !password) return res.status(400).json({ message: 'All fields required' });
    const existing = await findByEmail(email);
    if (existing) return res.status(409).json({ message: 'Email already registered' });
    const hashed = await bcrypt.hash(password, 10);
    const user = await createUser({ name, email, password: hashed });
    const token = jwt.sign({ id: user._id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1d' });
    res.status(201).json({ token });
  } catch (err) { next(err); }
});

router.post('/login', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    const user = await findByEmail(email);
    if (!user) return res.status(401).json({ message: 'Invalid credentials' });
    const match = await bcrypt.compare(password, user.password);
    if (!match) return res.status(401).json({ message: 'Invalid credentials' });
    const token = jwt.sign({ id: user._id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1d' });
    res.json({ token });
  } catch (err) { next(err); }
});

export default router;

Here is the updated src/routes/products.js:

import { Router } from 'express';
import { authenticate } from '../middleware/auth.js';
import { findAll, findById, createProduct, updateProduct, deleteProduct } from '../repositories/productRepository.js';

const router = Router();

router.get('/', async (req, res, next) => {
  try { res.json(await findAll()); } catch (err) { next(err); }
});

router.get('/:id', async (req, res, next) => {
  try {
    const product = await findById(req.params.id);
    if (!product) return res.status(404).json({ message: 'Product not found' });
    res.json(product);
  } catch (err) { next(err); }
});

router.post('/', authenticate, async (req, res, next) => {
  try {
    const product = await createProduct(req.body);
    res.status(201).json(product);
  } catch (err) { next(err); }
});

router.put('/:id', authenticate, async (req, res, next) => {
  try {
    const product = await updateProduct(req.params.id, req.body);
    if (!product) return res.status(404).json({ message: 'Product not found' });
    res.json(product);
  } catch (err) { next(err); }
});

router.delete('/:id', authenticate, async (req, res, next) => {
  try {
    await deleteProduct(req.params.id);
    res.status(204).send();
  } catch (err) { next(err); }
});

export default router;

Your backend now has a proper data layer with Mongoose schemas, indexed fields, and a clean repository pattern. Next up, you'll add a Redis caching layer to speed up product queries.

Add Redis Caching and WebSocket Support

Your API fetches products from MongoDB every single time a request comes in. That's fine for a handful of users, but under real traffic, hitting the database for unchanged data wastes time and resources.

In this step, you'll add two performance layers to your backend. First, a Redis cache that stores frequently-requested data in memory for instant retrieval. Second, Socket.io for real-time WebSocket connections so clients are notified instantly when products change.

In this step, get ready to:
  • Install and configure ioredis for in-memory caching.
  • Build cache middleware with TTL-based expiration and invalidation.
  • Integrate Socket.io with JWT-authenticated WebSocket connections and real-time product events.
Install packages and configure the Redis client

Before writing any caching logic, you need the ioredis client library to talk to Redis and socket.io for WebSocket communication.

  • Install both packages by running this command:
npm install ioredis socket.io

What do these packages do?

ioredis is a high-performance Redis client for Node.js. It handles the connection to your Redis server and provides methods for reading and writing cached data.

socket.io adds WebSocket support to your Express server. It enables persistent two-way connections between your backend and clients, so you can push updates instantly without clients polling.

  • Open your .env file and add the Redis connection URL on a new line at the bottom:
REDIS_URL=redis://redis:6379

Why redis://redis:6379?

This URL points to the Redis container you'll define later in Docker Compose. The hostname redis matches the service name in your Compose file, and 6379 is Redis's default port.

  • Create a new file at src/config/redis.js and add the Redis client configuration:
import Redis from 'ioredis';

// Connect to Redis using the URL from environment variables
const redis = new Redis(process.env.REDIS_URL);
redis.on('connect', () => console.log('Redis connected'));

export default redis;

What does this code do?

This creates a Redis client instance that connects to the URL stored in your REDIS_URL environment variable. The connect event listener logs a confirmation when the connection succeeds.

Exporting the client as a default means any file in your project can import it and run Redis commands like get, set, and del.

  • Save src/config/redis.js and .env.

Redis connection errors when you start the app?

The Redis client will attempt to connect immediately on import. If your Docker containers aren't running yet, you'll see connection refused errors. That's expected at this point. The connection will work once you bring up Docker Compose in a later step.

help me understand why Redis won't connect yet

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before continuing.

ⓧ I'd like to double check the full code

PORT=3000
MONGODB_URI=mongodb://mongodb:27017/ecommerce
JWT_SECRET=supersecretkey
REDIS_URL=redis://redis:6379
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);
redis.on('connect', () => console.log('Redis connected'));

export default redis;
Build the cache middleware and add invalidation

A cache is only useful if it serves fresh data. You need two pieces: middleware that checks the cache before hitting MongoDB, and invalidation logic that clears stale entries when products change.

  • Create a new file at src/middleware/cache.js with the caching middleware:
import redis from '../config/redis.js';

// Higher-order middleware that caches JSON responses
export const cacheMiddleware = (ttl = 60) => async (req, res, next) => {
  const key = `cache:${req.originalUrl}`;
  const cached = await redis.get(key);
  // Return cached data immediately if it exists
  if (cached) return res.json(JSON.parse(cached));
  // Override res.json to store the response in Redis before sending
  const originalJson = res.json.bind(res);
  res.json = (data) => {
    redis.setex(key, ttl, JSON.stringify(data));
    return originalJson(data);
  };
  next();
};

What does this code do?

This middleware intercepts GET requests before they reach your route handler. It builds a cache key from the request URL (e.g. cache:/api/products) and checks if Redis already has a response stored.

If the cache has data, it returns it immediately without touching MongoDB. If not, it overrides res.json to save the response in Redis with a TTL (time-to-live) of 60 seconds before sending it to the client.

  • Save src/middleware/cache.js.

Now you need the other half of caching: invalidation. When a product is created, updated, or deleted, the cached product list becomes stale and must be cleared.

  • Open src/repositories/productRepository.js and replace its entire contents with this updated version:
import Product from '../models/Product.js';
import redis from '../config/redis.js';

// Cache key matching what the middleware generates for /api/products
const CACHE_KEY = 'cache:/api/products';

export const findAll = () => Product.find();
export const findById = (id) => Product.findById(id);
export const createProduct = async (data) => {
  const product = await Product.create(data);
  // Invalidate the products list cache
  await redis.del(CACHE_KEY);
  return product;
};
export const updateProduct = async (id, data) => {
  const product = await Product.findByIdAndUpdate(id, data, { new: true });
  await redis.del(CACHE_KEY);
  return product;
};
export const deleteProduct = async (id) => {
  await Product.findByIdAndDelete(id);
  await redis.del(CACHE_KEY);
};

What does this code do?

Each write operation (create, update, delete) now calls redis.del(CACHE_KEY) after modifying the database. This deletes the cached product list so the next GET request fetches fresh data from MongoDB.

The CACHE_KEY constant matches the key pattern the cache middleware generates. This ensures the middleware and the invalidation logic stay in sync.

  • Save src/repositories/productRepository.js.

Stale data showing up after updates?

If you see old product data after creating or updating, verify that your CACHE_KEY string exactly matches the key the middleware generates. Both must be cache:/api/products.

help me debug my Redis cache invalidation

✔️ Awesome, I've got everything!

Great. Confirm both files are saved before moving on.

ⓧ I'd like to double check the full code

import redis from '../config/redis.js';

export const cacheMiddleware = (ttl = 60) => async (req, res, next) => {
  const key = `cache:${req.originalUrl}`;
  const cached = await redis.get(key);
  if (cached) return res.json(JSON.parse(cached));
  const originalJson = res.json.bind(res);
  res.json = (data) => {
    redis.setex(key, ttl, JSON.stringify(data));
    return originalJson(data);
  };
  next();
};
import Product from '../models/Product.js';
import redis from '../config/redis.js';

const CACHE_KEY = 'cache:/api/products';

export const findAll = () => Product.find();
export const findById = (id) => Product.findById(id);
export const createProduct = async (data) => {
  const product = await Product.create(data);
  await redis.del(CACHE_KEY);
  return product;
};
export const updateProduct = async (id, data) => {
  const product = await Product.findByIdAndUpdate(id, data, { new: true });
  await redis.del(CACHE_KEY);
  return product;
};
export const deleteProduct = async (id) => {
  await Product.findByIdAndDelete(id);
  await redis.del(CACHE_KEY);
};
Integrate Socket.io for real-time events

Caching speeds up reads, but clients still don't know when data changes unless they poll. WebSocket connections solve this by keeping a persistent channel open between server and client.

  • Create a new folder src/socket/ and add a file src/socket/index.js with the Socket.io setup:
import { Server } from 'socket.io';
import jwt from 'jsonwebtoken';

export let io;

export const initSocket = (httpServer) => {
  io = new Server(httpServer, { cors: { origin: '*' } });
  // Authenticate connections using JWT from handshake
  io.use((socket, next) => {
    const token = socket.handshake.auth?.token;
    if (!token) return next(new Error('Authentication error'));
    try {
      socket.user = jwt.verify(token, process.env.JWT_SECRET);
      next();
    } catch {
      next(new Error('Authentication error'));
    }
  });
  io.on('connection', (socket) => {
    console.log('Client connected:', socket.id);
    socket.on('disconnect', () => console.log('Client disconnected:', socket.id));
  });
  return io;
};

What does this code do?

The initSocket function creates a Socket.io server attached to your HTTP server. The io.use middleware runs before any connection completes. It extracts the JWT from the client's handshake and verifies it.

Only authenticated clients can connect. Their decoded user data is stored on socket.user for later use. The io variable is exported so other files (like your routes) can emit events to all connected clients.

  • Save src/socket/index.js.

Socket.io needs to attach to a raw HTTP server, not directly to Express. You need to update your entry point to create the HTTP server explicitly.

  • Open src/index.js and replace its entire contents with this updated version:
import http from 'http';
import express from 'express';
import morgan from 'morgan';
import { connectDB } from './config/database.js';
import { errorHandler } from './middleware/errorHandler.js';
import authRoutes from './routes/auth.js';
import productRoutes from './routes/products.js';
import { initSocket } from './socket/index.js';

const app = express();
// Create HTTP server so Socket.io can attach to it
const server = http.createServer(app);
const PORT = process.env.PORT || 3000;

connectDB();
initSocket(server);

app.use(express.json());
app.use(morgan('dev'));

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use('/api/auth', authRoutes);
app.use('/api/products', productRoutes);

app.use(errorHandler);

// Listen on the HTTP server, not the Express app
server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

export default app;

What changed here?

Two key changes: you now import Node's built-in http module and create the server with http.createServer(app). This wraps Express in a raw HTTP server that Socket.io can hook into.

The initSocket(server) call passes that server to Socket.io during startup. Finally, server.listen replaces app.listen so both Express routes and WebSocket connections use the same port.

  • Save src/index.js.

The final piece is wiring the cache middleware and Socket.io events into your product routes.

  • Open src/routes/products.js and replace its entire contents with this updated version:
import { Router } from 'express';
import { authenticate } from '../middleware/auth.js';
import { findAll, findById, createProduct, updateProduct, deleteProduct } from '../repositories/productRepository.js';
import { cacheMiddleware } from '../middleware/cache.js';
import { io } from '../socket/index.js';

const router = Router();

// Cache the product list for 60 seconds
router.get('/', cacheMiddleware(60), async (req, res, next) => {
  try { res.json(await findAll()); } catch (err) { next(err); }
});

router.get('/:id', async (req, res, next) => {
  try {
    const product = await findById(req.params.id);
    if (!product) return res.status(404).json({ message: 'Product not found' });
    res.json(product);
  } catch (err) { next(err); }
});

router.post('/', authenticate, async (req, res, next) => {
  try {
    const product = await createProduct(req.body);
    // Broadcast new product to all connected clients
    io.emit('product:created', product);
    res.status(201).json(product);
  } catch (err) { next(err); }
});

router.put('/:id', authenticate, async (req, res, next) => {
  try {
    const product = await updateProduct(req.params.id, req.body);
    if (!product) return res.status(404).json({ message: 'Product not found' });
    io.emit('product:updated', product);
    res.json(product);
  } catch (err) { next(err); }
});

router.delete('/:id', authenticate, async (req, res, next) => {
  try {
    await deleteProduct(req.params.id);
    io.emit('product:deleted', { id: req.params.id });
    res.status(204).send();
  } catch (err) { next(err); }
});

export default router;

What does this code do?

Two additions here. The cacheMiddleware(60) on the GET / route caches the full product list for 60 seconds. Subsequent requests within that window skip MongoDB entirely.

The io.emit calls in the POST, PUT, and DELETE handlers broadcast events (product:created, product:updated, product:deleted) to every connected WebSocket client. Any client listening for these events gets instant notification of changes.

  • Save src/routes/products.js.

Getting an error about io being undefined?

Make sure initSocket(server) is called in src/index.js before Express registers the product routes. The io export from src/socket/index.js is undefined until initSocket runs and assigns it.

help me fix io being undefined in my product routes

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files.

ⓧ I'd like to double check the full code

import { Server } from 'socket.io';
import jwt from 'jsonwebtoken';

export let io;

export const initSocket = (httpServer) => {
  io = new Server(httpServer, { cors: { origin: '*' } });
  io.use((socket, next) => {
    const token = socket.handshake.auth?.token;
    if (!token) return next(new Error('Authentication error'));
    try {
      socket.user = jwt.verify(token, process.env.JWT_SECRET);
      next();
    } catch {
      next(new Error('Authentication error'));
    }
  });
  io.on('connection', (socket) => {
    console.log('Client connected:', socket.id);
    socket.on('disconnect', () => console.log('Client disconnected:', socket.id));
  });
  return io;
};
import http from 'http';
import express from 'express';
import morgan from 'morgan';
import { connectDB } from './config/database.js';
import { errorHandler } from './middleware/errorHandler.js';
import authRoutes from './routes/auth.js';
import productRoutes from './routes/products.js';
import { initSocket } from './socket/index.js';

const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 3000;

connectDB();
initSocket(server);

app.use(express.json());
app.use(morgan('dev'));

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use('/api/auth', authRoutes);
app.use('/api/products', productRoutes);

app.use(errorHandler);

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

export default app;
import { Router } from 'express';
import { authenticate } from '../middleware/auth.js';
import { findAll, findById, createProduct, updateProduct, deleteProduct } from '../repositories/productRepository.js';
import { cacheMiddleware } from '../middleware/cache.js';
import { io } from '../socket/index.js';

const router = Router();

router.get('/', cacheMiddleware(60), async (req, res, next) => {
  try { res.json(await findAll()); } catch (err) { next(err); }
});

router.get('/:id', async (req, res, next) => {
  try {
    const product = await findById(req.params.id);
    if (!product) return res.status(404).json({ message: 'Product not found' });
    res.json(product);
  } catch (err) { next(err); }
});

router.post('/', authenticate, async (req, res, next) => {
  try {
    const product = await createProduct(req.body);
    io.emit('product:created', product);
    res.status(201).json(product);
  } catch (err) { next(err); }
});

router.put('/:id', authenticate, async (req, res, next) => {
  try {
    const product = await updateProduct(req.params.id, req.body);
    if (!product) return res.status(404).json({ message: 'Product not found' });
    io.emit('product:updated', product);
    res.json(product);
  } catch (err) { next(err); }
});

router.delete('/:id', authenticate, async (req, res, next) => {
  try {
    await deleteProduct(req.params.id);
    io.emit('product:deleted', { id: req.params.id });
    res.status(204).send();
  } catch (err) { next(err); }
});

export default router;

Your backend now has two powerful performance layers. Redis handles caching so repeated reads skip the database, and Socket.io pushes real-time updates to connected clients. Next up, you'll write tests to make sure everything works reliably.

Write Unit and Integration Tests

Your API now has authentication, CRUD operations, caching, and real-time events. Before adding more infrastructure, you need confidence that your existing code works correctly.

Automated tests let you verify your middleware and routes behave as expected without manually sending requests every time you make a change. In this step, you'll set up Jest for unit tests and supertest for integration tests against your Express app.

In this step, get ready to:
  • Install Jest and configure it for ES modules.
  • Write unit tests for the authentication middleware.
  • Write integration tests for API endpoints using supertest.
Install Jest and configure for ES modules

Your project uses ES modules ("type": "module" in package.json). Jest needs special configuration to work with ESM imports.

  • Install Jest and supertest as dev dependencies by running:
npm install --save-dev jest supertest

What are these packages?

Jest is a JavaScript testing framework that provides test runners, assertions, and mocking out of the box. Supertest lets you make HTTP requests to your Express app in tests without starting a real server.

  • Open package.json and add the test script to the scripts section. Also add a top-level jest configuration key. Copy the following into your package.json:
{
  "name": "ecommerce-backend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js",
    "test": "node --experimental-vm-modules node_modules/.bin/jest"
  },
  "jest": {
    "testEnvironment": "node"
  },
  "dependencies": {
    "express": "^5.0.0",
    "morgan": "^1.10.0",
    "jsonwebtoken": "^9.0.0",
    "bcryptjs": "^2.4.3",
    "mongoose": "^8.0.0",
    "ioredis": "^5.0.0",
    "socket.io": "^4.0.0"
  },
  "devDependencies": {
    "jest": "^29.0.0",
    "supertest": "^6.0.0"
  }
}

What does this configuration do?

The --experimental-vm-modules flag tells Node.js to enable ESM support inside Jest. Without it, Jest cannot process your import statements.

Setting testEnvironment to node tells Jest to run tests in a Node.js environment rather than a browser-like one (which would be jsdom).

  • Save package.json.

Seeing module errors when you run tests later?

Make sure "type": "module" is present in your package.json. Also confirm the test script uses node --experimental-vm-modules and not just jest directly.

Help me fix module errors in my Jest test setup.

Write unit tests for the authentication middleware

Unit tests verify individual pieces of your code in isolation. You'll test the authenticate middleware to confirm it rejects requests without tokens and allows requests with valid tokens.

  • Create the test directory structure by running:
mkdir -p tests/middleware
  • Create tests/middleware/auth.test.js and add the following test code:
import { authenticate } from '../../src/middleware/auth.js';
import jwt from 'jsonwebtoken';

// Set a test-only secret for JWT verification
process.env.JWT_SECRET = 'testsecret';

describe('authenticate middleware', () => {
  it('returns 401 if no token', () => {
    // Simulate a request with no authorization header
    const req = { headers: {} };
    const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
    authenticate(req, res, jest.fn());
    expect(res.status).toHaveBeenCalledWith(401);
  });

  it('calls next with valid token', () => {
    // Create a valid JWT and attach it as a Bearer token
    const token = jwt.sign({ id: '1', role: 'user' }, 'testsecret');
    const req = { headers: { authorization: `Bearer ${token}` } };
    const res = {};
    const next = jest.fn();
    authenticate(req, res, next);
    expect(next).toHaveBeenCalled();
  });
});

What does this test do?

The first test creates a fake request with no Authorization header. It checks that the middleware responds with a 401 status. jest.fn() creates mock functions that track how they're called.

The second test creates a valid JWT using jsonwebtoken, attaches it to a fake request, and verifies that next() is called. This means the middleware allowed the request through.

  • Save tests/middleware/auth.test.js.
Write integration tests for API endpoints using supertest

Integration tests verify that your full request/response cycle works correctly. Supertest imports your Express app and makes real HTTP requests against it without needing a running server.

  • Create the routes test directory by running:
mkdir -p tests/routes
  • Create tests/routes/products.test.js and add the following integration test:
import request from 'supertest';
import app from '../../src/index.js';

describe('GET /health', () => {
  it('returns ok', async () => {
    // Send a GET request to the health endpoint
    const res = await request(app).get('/health');
    expect(res.status).toBe(200);
    expect(res.body.status).toBe('ok');
  });
});

What does this test do?

This test imports your Express app directly from src/index.js and passes it to supertest. Supertest binds the app to an ephemeral port automatically, so you don't need Docker or a running server.

The test hits the /health endpoint and asserts it returns a 200 status with {"status": "ok"} in the response body.

  • Save tests/routes/products.test.js.
  • Run the test suite by running:
npm test
  • You should see output showing your test suites passing, with green checkmarks next to each test case.

Tests failing or not found?

If Jest reports no tests found, check that your test files end in .test.js and are inside the tests/ directory.

If you see connection errors from MongoDB or Redis, that is expected if those services aren't running locally. The health endpoint test should still pass because it doesn't touch the database. For the auth middleware tests, they use mocked objects and don't require a database connection.

Help me debug why my Jest tests are failing or not being discovered.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files.

ⓧ I'd like to double check the full code

Here is the complete package.json:

{
  "name": "ecommerce-backend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js",
    "test": "node --experimental-vm-modules node_modules/.bin/jest"
  },
  "jest": {
    "testEnvironment": "node"
  },
  "dependencies": {
    "express": "^5.0.0",
    "morgan": "^1.10.0",
    "jsonwebtoken": "^9.0.0",
    "bcryptjs": "^2.4.3",
    "mongoose": "^8.0.0",
    "ioredis": "^5.0.0",
    "socket.io": "^4.0.0"
  },
  "devDependencies": {
    "jest": "^29.0.0",
    "supertest": "^6.0.0"
  }
}

Here is tests/middleware/auth.test.js:

import { authenticate } from '../../src/middleware/auth.js';
import jwt from 'jsonwebtoken';

process.env.JWT_SECRET = 'testsecret';

describe('authenticate middleware', () => {
  it('returns 401 if no token', () => {
    const req = { headers: {} };
    const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
    authenticate(req, res, jest.fn());
    expect(res.status).toHaveBeenCalledWith(401);
  });

  it('calls next with valid token', () => {
    const token = jwt.sign({ id: '1', role: 'user' }, 'testsecret');
    const req = { headers: { authorization: `Bearer ${token}` } };
    const res = {};
    const next = jest.fn();
    authenticate(req, res, next);
    expect(next).toHaveBeenCalled();
  });
});

Here is tests/routes/products.test.js:

import request from 'supertest';
import app from '../../src/index.js';

describe('GET /health', () => {
  it('returns ok', async () => {
    const res = await request(app).get('/health');
    expect(res.status).toBe(200);
    expect(res.body.status).toBe('ok');
  });
});

Your tests are passing. Next up, you'll containerize the entire backend with Docker so all these services run together reliably.

Containerize with Docker and Configure Nginx

Your Node.js API works great on your local machine, but production apps need isolated, reproducible environments.

In this step, you will containerize the entire backend stack with Docker and Docker Compose, configure Nginx as a reverse proxy, and wire up RabbitMQ for event-driven push notifications via Firebase Cloud Messaging.

In this step, get ready to:
  • Write a multi-stage Dockerfile and Docker Compose configuration for five services.
  • Configure Nginx as a reverse proxy with WebSocket support and rate limiting.
  • Implement a RabbitMQ message queue and Firebase push notification service.
Write the multi-stage Dockerfile

A multi-stage Dockerfile separates the build process from the production image.

The first stage installs dependencies, and the second stage copies only what is needed to run the app. This keeps the final image small and secure.

  • Create a file called Dockerfile in the root of your project folder (next to package.json).
  • Paste the following code into it:
# Build stage: install all dependencies
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

# Production stage: run the app with minimal footprint
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=build /app .
EXPOSE 3000
CMD ["node", "src/index.js"]

What does this Dockerfile do?

  • FROM node:20-alpine AS build starts a lightweight build stage based on Node.js 20.
  • npm ci installs exact versions from your lockfile for reproducible builds.
  • The second FROM creates a clean production image that copies only the built output.
  • EXPOSE 3000 documents which port the container listens on.
  • Save Dockerfile.
Create the Docker Compose configuration

Docker Compose lets you define and run multiple containers as a single stack. Your app needs five services: the Node.js app, MongoDB, Redis, Nginx, and RabbitMQ.

  • Create a file called docker-compose.yaml in the root of your project folder.
  • Paste the following configuration:
version: '3.9'
services:
  app:
    build: .
    env_file: .env
    networks:
      - app-network
    depends_on:
      - mongodb
      - redis
      - rabbitmq
  mongodb:
    image: mongo:7
    volumes:
      - mongo_data:/data/db
    networks:
      - app-network
  redis:
    image: redis:7-alpine
    networks:
      - app-network
  nginx:
    image: nginx:alpine
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    networks:
      - app-network
    depends_on:
      - app
  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - '5672:5672'
      - '15672:15672'
    networks:
      - app-network
volumes:
  mongo_data:
networks:
  app-network:
    driver: bridge

What does this Compose file do?

  • app builds your Dockerfile and reads environment variables from .env.
  • mongodb stores data in a persistent volume called mongo_data so data survives container restarts.
  • nginx exposes ports 80 and 443 and mounts your config as read-only.
  • rabbitmq exposes the management UI on port 15672 for debugging queues.
  • All services share the app-network bridge network so they can reach each other by service name.
  • Save docker-compose.yaml.

Why a shared bridge network?

Docker containers are isolated by default. By placing all services on the same bridge network, they can communicate using their service names as hostnames.

For example, your Node.js app connects to MongoDB at mongodb://mongodb:27017/ecommerce because the service is named mongodb in the Compose file.

Configure Nginx as a reverse proxy

Nginx sits in front of your Node.js app and handles incoming traffic. It adds rate limiting to prevent abuse and forwards WebSocket upgrade requests so Socket.io connections pass through correctly.

  • Create a file called nginx.conf in the root of your project folder.
  • Paste the following configuration:
events {}
http {
  # Rate limiting: 10 requests per second per IP
  limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
  server {
    listen 80;
    location / {
      limit_req zone=api burst=20 nodelay;
      # Reverse proxy to the Node.js app service
      proxy_pass http://app:3000;
      proxy_http_version 1.1;
      # WebSocket upgrade headers for Socket.io
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';
      proxy_set_header Host $host;
      proxy_cache_bypass $http_upgrade;
    }
  }
}

What does this Nginx config do?

  • limit_req_zone creates a shared memory zone that tracks request rates per IP address. It allows 10 requests per second with a burst of 20.
  • proxy_pass http://app:3000 forwards all traffic to the Node.js container. The hostname app resolves via the Docker bridge network.
  • The Upgrade and Connection headers allow WebSocket connections from Socket.io to pass through the proxy without being dropped.
  • Save nginx.conf.

Why a reverse proxy?

In production, you never expose Node.js directly to the internet. Nginx handles SSL termination, static file serving, and rate limiting far more efficiently than Node.js.

Placing Nginx in front of your app gives you a battle-tested layer of defense without modifying application code.

Implement RabbitMQ message queue and Firebase push notifications

When a product is updated, you want to notify users asynchronously. A message queue decouples the API response from the notification delivery.

RabbitMQ holds events in a queue. A separate consumer process picks them up and sends push notifications through Firebase Cloud Messaging.

  • Install the amqplib and firebase-admin packages by running:
npm install amqplib firebase-admin
  • Open .env and add these two new environment variables at the end:
RABBITMQ_URL=amqp://rabbitmq:5672
FIREBASE_SERVICE_ACCOUNT={}

What are these environment variables?

  • RABBITMQ_URL points to the RabbitMQ container using its Docker service name as the hostname.
  • FIREBASE_SERVICE_ACCOUNT holds your Firebase credentials as a JSON string. For local development, an empty object placeholder works until you set up a real Firebase project.
  • Save .env.
  • Create a new folder called src/queue by running:
mkdir src/queue
  • Create a file called src/queue/producer.js and paste the following code:
import amqplib from 'amqplib';

// Queue name shared between producer and consumer
const QUEUE = 'notifications';

// Publishes a notification event to the RabbitMQ queue
export const publishNotification = async (message) => {
  const conn = await amqplib.connect(process.env.RABBITMQ_URL);
  const channel = await conn.createChannel();
  await channel.assertQueue(QUEUE, { durable: true });
  channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
  await channel.close();
  await conn.close();
};

What does the producer do?

  • It connects to RabbitMQ, creates a channel, and ensures the notifications queue exists.
  • durable: true means the queue survives RabbitMQ restarts.
  • After sending the message, it cleans up by closing the channel and connection.
  • Save src/queue/producer.js.
  • Create a file called src/queue/consumer.js and paste the following code:
import amqplib from 'amqplib';
import { sendPushNotification } from '../services/fcmService.js';
import { findById } from '../repositories/userRepository.js';

// Same queue name the producer publishes to
const QUEUE = 'notifications';

// Starts listening for messages on the notifications queue
export const startConsumer = async () => {
  const conn = await amqplib.connect(process.env.RABBITMQ_URL);
  const channel = await conn.createChannel();
  await channel.assertQueue(QUEUE, { durable: true });
  channel.consume(QUEUE, async (msg) => {
    if (!msg) return;
    const event = JSON.parse(msg.content.toString());
    // Look up the target user and send a push notification
    const user = await findById(event.userId);
    if (user?.fcmToken) {
      await sendPushNotification(user.fcmToken, event.title, event.body);
    }
    channel.ack(msg);
  });
};

What does the consumer do?

  • It listens to the same notifications queue and processes each message as it arrives.
  • For each event, it looks up the user in MongoDB and sends a push notification if they have a valid fcmToken.
  • channel.ack(msg) tells RabbitMQ the message was processed successfully, removing it from the queue.
  • Save src/queue/consumer.js.
  • Create a new folder called src/services by running:
mkdir src/services
  • Create a file called src/services/fcmService.js and paste the following code:
import admin from 'firebase-admin';

// Initialize Firebase Admin only once
if (!admin.apps.length) {
  admin.initializeApp({
    credential: admin.credential.cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT))
  });
}

// Sends a push notification to a specific device token
export const sendPushNotification = async (token, title, body) => {
  await admin.messaging().send({ token, notification: { title, body } });
};

What does the FCM service do?

  • It initializes the Firebase Admin SDK using credentials from your environment variable.
  • The admin.apps.length check ensures Firebase only initializes once, even if the module is imported multiple times.
  • sendPushNotification sends a notification payload to a specific device token via Firebase Cloud Messaging.
  • Save src/services/fcmService.js.

Now you need to wire the consumer into your app so it starts listening when the server boots.

  • In src/index.js, find this line near the top of the file:
import { initSocket } from './socket/index.js';
  • Add the following import directly below it:
import { startConsumer } from './queue/consumer.js';
  • Next, find the line that says initSocket(server); and add the following line directly below it:
startConsumer();

What does this change?

When your server starts, it now connects to RabbitMQ and begins listening for notification events. Any message published to the queue will be automatically processed and forwarded as a push notification.

  • Save src/index.js.
  • Bring up all five services by running:
docker compose up --build

You should see logs from MongoDB, Redis, RabbitMQ, and your Node.js app starting up. Nginx will begin proxying traffic on port 80.

Services failing to start?

  • Make sure Docker Desktop is running before you execute docker compose up --build.
  • If port 80 is in use, another process is blocking Nginx. Stop other web servers or change the Nginx port mapping in docker-compose.yaml.
  • If the app container exits with a module not found error, ensure you ran npm install amqplib firebase-admin before building.

Help me debug my Docker Compose stack

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each new and modified file in this step.

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

FROM node:20-alpine AS production
WORKDIR /app
COPY --from=build /app .
EXPOSE 3000
CMD ["node", "src/index.js"]
version: '3.9'
services:
  app:
    build: .
    env_file: .env
    networks:
      - app-network
    depends_on:
      - mongodb
      - redis
      - rabbitmq
  mongodb:
    image: mongo:7
    volumes:
      - mongo_data:/data/db
    networks:
      - app-network
  redis:
    image: redis:7-alpine
    networks:
      - app-network
  nginx:
    image: nginx:alpine
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    networks:
      - app-network
    depends_on:
      - app
  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - '5672:5672'
      - '15672:15672'
    networks:
      - app-network
volumes:
  mongo_data:
networks:
  app-network:
    driver: bridge
events {}
http {
  limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
  server {
    listen 80;
    location / {
      limit_req zone=api burst=20 nodelay;
      proxy_pass http://app:3000;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';
      proxy_set_header Host $host;
      proxy_cache_bypass $http_upgrade;
    }
  }
}
PORT=3000
MONGODB_URI=mongodb://mongodb:27017/ecommerce
JWT_SECRET=supersecretkey
REDIS_URL=redis://redis:6379
RABBITMQ_URL=amqp://rabbitmq:5672
FIREBASE_SERVICE_ACCOUNT={}
import amqplib from 'amqplib';

const QUEUE = 'notifications';

export const publishNotification = async (message) => {
  const conn = await amqplib.connect(process.env.RABBITMQ_URL);
  const channel = await conn.createChannel();
  await channel.assertQueue(QUEUE, { durable: true });
  channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
  await channel.close();
  await conn.close();
};
import amqplib from 'amqplib';
import { sendPushNotification } from '../services/fcmService.js';
import { findById } from '../repositories/userRepository.js';

const QUEUE = 'notifications';

export const startConsumer = async () => {
  const conn = await amqplib.connect(process.env.RABBITMQ_URL);
  const channel = await conn.createChannel();
  await channel.assertQueue(QUEUE, { durable: true });
  channel.consume(QUEUE, async (msg) => {
    if (!msg) return;
    const event = JSON.parse(msg.content.toString());
    const user = await findById(event.userId);
    if (user?.fcmToken) {
      await sendPushNotification(user.fcmToken, event.title, event.body);
    }
    channel.ack(msg);
  });
};
import admin from 'firebase-admin';

if (!admin.apps.length) {
  admin.initializeApp({
    credential: admin.credential.cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT))
  });
}

export const sendPushNotification = async (token, title, body) => {
  await admin.messaging().send({ token, notification: { title, body } });
};
import http from 'http';
import express from 'express';
import morgan from 'morgan';
import { connectDB } from './config/database.js';
import { errorHandler } from './middleware/errorHandler.js';
import authRoutes from './routes/auth.js';
import productRoutes from './routes/products.js';
import { initSocket } from './socket/index.js';
import { startConsumer } from './queue/consumer.js';

const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 3000;

connectDB();
initSocket(server);
startConsumer();

app.use(express.json());
app.use(morgan('dev'));

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use('/api/auth', authRoutes);
app.use('/api/products', productRoutes);

app.use(errorHandler);

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

export default app;

Your entire backend is now containerized and running as a five-service stack. Next up, you will create a CI/CD pipeline with GitHub Actions that automatically tests, builds, and pushes your Docker images.

Set Up CI/CD with GitHub Actions and GitFlow

Your containerized backend is running smoothly with Docker Compose. The next challenge is making sure every code change gets tested and built automatically before it reaches production.

In this step, you'll create a CI/CD pipeline using GitHub Actions that runs your tests, builds a Docker image, and pushes it to Docker Hub on every push to main. You'll also establish a GitFlow branching strategy with branch protection rules to keep your codebase stable.

In this step, get ready to:
  • Create a GitHub Actions CI/CD workflow that tests, builds, and pushes Docker images.
  • Configure repository secrets and branch protection rules on GitHub.
  • Set up GitFlow branching and document it in CONTRIBUTING.md.
Create the GitHub Actions workflow

GitHub Actions workflows live inside a .github/workflows/ directory in your repository. Each YAML file in that directory defines a pipeline that GitHub runs automatically based on triggers you configure.

  • Create the workflow directory structure by running these commands:
mkdir -p .github/workflows
  • Confirm the directory exists by running:
ls .github

You should see workflows in the output.

  • Create the CI/CD workflow file by adding the following content to .github/workflows/ci.yml:
name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKER_USERNAME }}/ecommerce-backend:latest

What does this workflow do?

The on section triggers the pipeline on every push to main and on pull requests targeting main.

  • The test job checks out the code, installs Node.js 20, runs npm ci to install dependencies, and runs npm test to execute your Jest tests.
  • The build-and-push job only runs after test passes (needs: test) and only on direct pushes to main (not on pull requests). It logs into Docker Hub and pushes the built image.
  • Save .github/workflows/ci.yml.

Why two separate jobs?

Separating test from build-and-push means pull requests get fast feedback from the test job without triggering a Docker image push. The if: github.ref == 'refs/heads/main' condition ensures Docker images only get pushed when code lands on main, not on every PR.

Workflow file not recognized?

  • Make sure the file path is exactly .github/workflows/ci.yml with the leading dot.
  • YAML indentation must use spaces, not tabs. Each level is 2 spaces.

Why isn't GitHub Actions detecting my workflow file?

Configure GitHub secrets and branch protection

Your workflow references secrets.DOCKER_USERNAME and secrets.DOCKER_PASSWORD to authenticate with Docker Hub. You need to add these as repository secrets on GitHub so the workflow can access them securely.

  • Open your repository on GitHub in your browser.
  • Click Settings in the top navigation bar of the repository.
  • In the left sidebar under the Security section, select Secrets and variables, then click Actions.
  • Click the Secrets tab.
  • Click New repository secret.
  • In the Name field, type DOCKER_USERNAME.
  • In the Secret field, enter your Docker Hub username.
  • Click Add secret.
  • Repeat the process to add a second secret named DOCKER_PASSWORD with your Docker Hub password or access token as the value.

What are GitHub repository secrets?

Repository secrets are encrypted variables that GitHub Actions can access at runtime. They never appear in logs because GitHub automatically redacts them. This keeps credentials like your Docker Hub password safe even in public repositories.

Now set up branch protection rules to prevent direct pushes to main.

  • In Settings, click Branches in the left sidebar under the Code and automation section.
  • Click Add rule next to Branch protection rules.
  • Under Branch name pattern, type main.
  • Check Require a pull request before merging.
  • Check Require status checks to pass before merging.
  • In the status checks search field, search for and select the test job from your CI workflow.
  • Click Create to save the rule.

Status checks need a first run

The test status check only appears in the search field after the workflow has run at least once. Push your workflow file to main first, then come back to configure this setting.

Set up GitFlow branching and CONTRIBUTING.md

GitFlow is a branching model that separates production code from active development. It uses dedicated branches for features, releases, and hotfixes so that main always contains stable, deployable code.

  • Create the develop branch by running:
git checkout -b develop
git push -u origin develop
git checkout main

What is the develop branch?

In GitFlow, develop is the integration branch where completed features are merged before a release. Feature branches (feature/*) branch off from develop and merge back into it. When develop is stable enough for a release, a release branch is created and eventually merged into main.

  • Create CONTRIBUTING.md in the root of your project with the following content:
# Contributing

## Branching Strategy (GitFlow)

- `main`: production-ready code
- `develop`: integration branch
- `feature/*`: new features branched from develop
- `release/*`: release preparation branched from develop
- `hotfix/*`: urgent fixes branched from main

## Merge Strategy
- Feature branches merge into develop via Pull Request
- Release branches merge into main and develop
- Hotfix branches merge into main and develop

## Branch Protection (main)
- Require pull request before merging
- Require status checks to pass (CI)
- No direct pushes

What does CONTRIBUTING.md do?

This file documents your team's branching conventions so anyone contributing to the project knows how to name branches, where to merge them, and what rules main enforces. GitHub automatically links to this file when someone opens a new pull request.

  • Save CONTRIBUTING.md.
  • Commit and push the workflow file and CONTRIBUTING.md by running:
git add .github/workflows/ci.yml CONTRIBUTING.md
git commit -m "Add CI/CD workflow and contributing guide"
git push origin main
  • Open the Actions tab in your GitHub repository.

You should see a new workflow run triggered by your push. The test job runs first, and if it passes, the build-and-push job builds and pushes your Docker image to Docker Hub.

Pipeline failing on the test job?

  • Make sure npm test passes locally before pushing. The CI runner uses the same command.
  • Check that your package.json includes both jest and supertest in devDependencies.
  • Verify the workflow YAML indentation uses 2 spaces per level.

Why is my GitHub Actions CI pipeline failing?

✔️ Awesome, I've got everything!

Great. Double check you've saved both files and your pipeline is green in the Actions tab.

ⓧ I'd like to double check the full code

Here is the complete .github/workflows/ci.yml:

name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKER_USERNAME }}/ecommerce-backend:latest

And the complete CONTRIBUTING.md:

# Contributing

## Branching Strategy (GitFlow)

- `main`: production-ready code
- `develop`: integration branch
- `feature/*`: new features branched from develop
- `release/*`: release preparation branched from develop
- `hotfix/*`: urgent fixes branched from main

## Merge Strategy
- Feature branches merge into develop via Pull Request
- Release branches merge into main and develop
- Hotfix branches merge into main and develop

## Branch Protection (main)
- Require pull request before merging
- Require status checks to pass (CI)
- No direct pushes

Your CI/CD pipeline is live and your branching strategy is documented. Next up, you'll integrate RabbitMQ message queues to add event-driven notifications to your backend.

Build the Flutter App with Clean Architecture

Your backend is fully containerized and your CI/CD pipeline is live. The server side is ready to receive requests.

Now you need a mobile client to consume those APIs. In this step, you will create a Flutter app structured with Clean Architecture, implement BLoC state management for authentication and products, and write unit tests to verify your logic.

In this step, get ready to:
  • Create a Flutter project with Clean Architecture folder structure and core dependencies.
  • Implement AuthCubit and ProductBloc for state management.
  • Write and run unit tests for both BLoC classes.
Create the Flutter project and configure dependencies

Clean Architecture separates your app into independent layers so that changes in one layer do not break another. You will scaffold this structure from the start.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Flutter project by running this command:
flutter create --project-name ecommerce_app flutter_app

What does this command do?

The flutter create command scaffolds a new Flutter application. The --project-name flag sets the Dart package name to ecommerce_app while creating the project inside a folder called flutter_app.

  • Move into the new project folder by running:
cd flutter_app
  • Create the Clean Architecture folder structure by running these commands:
mkdir -p lib/core/network
mkdir -p lib/features/auth/data/datasources
mkdir -p lib/features/auth/data/models
mkdir -p lib/features/auth/data/repositories
mkdir -p lib/features/auth/domain/entities
mkdir -p lib/features/auth/domain/repositories
mkdir -p lib/features/auth/domain/usecases
mkdir -p lib/features/auth/presentation/cubit
mkdir -p lib/features/auth/presentation/pages
mkdir -p lib/features/auth/presentation/widgets
mkdir -p lib/features/products/data/datasources
mkdir -p lib/features/products/data/models
mkdir -p lib/features/products/data/repositories
mkdir -p lib/features/products/domain/entities
mkdir -p lib/features/products/domain/repositories
mkdir -p lib/features/products/domain/usecases
mkdir -p lib/features/products/presentation/bloc
mkdir -p lib/features/products/presentation/pages
mkdir -p lib/features/products/presentation/widgets
mkdir -p lib/shared

What is Clean Architecture in Flutter?

Clean Architecture organizes code into three layers. The Data layer handles API calls and local storage. The Domain layer defines business entities and use cases. The Presentation layer manages UI and state with BLoC or Cubit.

Each feature (auth, products) gets its own set of these three layers. This keeps features isolated and testable.

  • Confirm the folders exist by running:
ls lib/features

You should see auth and products listed in the output.

  • Replace the contents of pubspec.yaml with the following to add your project dependencies:
name: ecommerce_app
description: E-commerce Flutter app with Clean Architecture
environment:
  sdk: '>=3.0.0 <4.0.0'
dependencies:
  flutter:
    sdk: flutter
  get_it: ^7.6.0
  dio: ^5.4.0
  flutter_bloc: ^8.1.0
  socket_io_client: ^2.0.0
  firebase_core: ^2.24.0
  firebase_messaging: ^14.7.0
dev_dependencies:
  flutter_test:
    sdk: flutter
  bloc_test: ^9.1.0
  mocktail: ^1.0.0

What are these packages?

The get_it package provides dependency injection. dio is an HTTP client. flutter_bloc provides the BLoC and Cubit state management pattern.

The socket_io_client connects to your backend's WebSocket server. firebase_messaging handles push notifications. bloc_test and mocktail are for unit testing your BLoC classes.

  • Install the dependencies by running:
flutter pub get
  • You should see a message confirming that all packages were resolved successfully.

Seeing dependency resolution errors?

Make sure you saved pubspec.yaml before running flutter pub get. Check that your indentation uses spaces (not tabs) and matches the YAML format exactly.

help me fix dependency resolution errors in my Flutter pubspec.yaml

Now create the Dio HTTP client with a JWT interceptor that attaches authentication tokens to every request.

  • Create the file lib/core/network/dio_client.dart and add the following code:
import 'package:dio/dio.dart';

class DioClient {
  final Dio dio;

  DioClient({required this.dio}) {
    dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) {
        final token = _getToken();
        if (token != null) {
          options.headers['Authorization'] = 'Bearer \$token';
        }
        handler.next(options);
      },
    ));
  }

  String? _getToken() => null; // Replaced by auth state
}

What does this code do?

The DioClient class wraps the Dio HTTP client and adds an interceptor. Every outgoing request passes through onRequest, which attaches the JWT token as a Bearer header.

The _getToken() method returns null for now. You will replace this with a real token lookup once the auth flow is connected.

  • Save lib/core/network/dio_client.dart.
Implement BLoC state management for auth and products

BLoC separates business logic from the UI. Cubit is a simpler variant that uses function calls to emit state changes. You will use Cubit for auth (simple login/register flow) and a full Bloc for products (complex event-driven state).

  • Create the file lib/features/auth/domain/entities/user_entity.dart and add:
class UserEntity {
  final String id;
  final String name;
  final String email;
  final String token;

  const UserEntity({required this.id, required this.name, required this.email, required this.token});
}

What is a domain entity?

A domain entity represents a core business object. UserEntity defines what a user looks like in your app, independent of any API response format or database schema.

  • Save user_entity.dart.
  • Create the file lib/features/auth/presentation/cubit/auth_cubit.dart and add:
import 'package:flutter_bloc/flutter_bloc.dart';

abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState { final String token; AuthAuthenticated(this.token); }
class AuthError extends AuthState { final String message; AuthError(this.message); }

class AuthCubit extends Cubit<AuthState> {
  AuthCubit() : super(AuthInitial());

  Future<void> login(String email, String password) async {
    emit(AuthLoading());
    // TODO: call repository
    emit(AuthAuthenticated('token'));
  }

  Future<void> register(String name, String email, String password) async {
    emit(AuthLoading());
    // TODO: call repository
    emit(AuthAuthenticated('token'));
  }
}

What does AuthCubit do?

AuthCubit manages the authentication state. It starts in AuthInitial, transitions to AuthLoading when the user triggers login or register, and then emits AuthAuthenticated with the JWT token on success.

Cubit uses simple function calls (login, register) instead of event classes. This makes it ideal for straightforward flows where you do not need complex event handling.

  • Save auth_cubit.dart.
  • Create the file lib/features/products/domain/entities/product_entity.dart and add:
class ProductEntity {
  final String id;
  final String name;
  final String description;
  final double price;
  final String category;
  final int stock;

  const ProductEntity({required this.id, required this.name, required this.description, required this.price, required this.category, required this.stock});
}
  • Save product_entity.dart.
  • Create the file lib/features/products/presentation/bloc/product_bloc.dart and add:
import 'package:flutter_bloc/flutter_bloc.dart';

abstract class ProductEvent {}
class LoadProducts extends ProductEvent {}
class AddProduct extends ProductEvent { final Map<String, dynamic> data; AddProduct(this.data); }
class UpdateProduct extends ProductEvent { final String id; final Map<String, dynamic> data; UpdateProduct(this.id, this.data); }
class DeleteProduct extends ProductEvent { final String id; DeleteProduct(this.id); }

abstract class ProductState {}
class ProductInitial extends ProductState {}
class ProductLoading extends ProductState {}
class ProductLoaded extends ProductState { final List<dynamic> products; ProductLoaded(this.products); }
class ProductError extends ProductState { final String message; ProductError(this.message); }

class ProductBloc extends Bloc<ProductEvent, ProductState> {
  ProductBloc() : super(ProductInitial()) {
    on<LoadProducts>((event, emit) async {
      emit(ProductLoading());
      // TODO: call repository
      emit(ProductLoaded([]));
    });
  }
}

What is the difference between Bloc and Cubit?

A full Bloc uses event classes (LoadProducts, AddProduct, etc.) to trigger state changes. This gives you a clear audit trail of what caused each state transition.

Cubit skips events and uses direct function calls. Use Cubit for simple flows (auth) and Bloc for complex features with multiple event types (product CRUD).

  • Save product_bloc.dart.
Write and run unit tests

The bloc_test package provides a blocTest helper that makes it easy to assert state transitions without manually subscribing to streams.

  • Create the file test/auth_cubit_test.dart and add:
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ecommerce_app/features/auth/presentation/cubit/auth_cubit.dart';

void main() {
  group('AuthCubit', () {
    blocTest<AuthCubit, AuthState>(
      'emits [AuthLoading, AuthAuthenticated] when login called',
      build: () => AuthCubit(),
      act: (cubit) => cubit.login('test@test.com', 'password'),
      expect: () => [isA<AuthLoading>(), isA<AuthAuthenticated>()],
    );
  });
}

What does this test do?

The blocTest helper builds an AuthCubit, calls login on it, and then asserts that the emitted states match the expected sequence: first AuthLoading, then AuthAuthenticated.

  • Save test/auth_cubit_test.dart.
  • Run the tests by executing:
flutter test
  • You should see output confirming all tests pass.

Tests failing with import errors?

Verify that the import path in your test file matches your project structure exactly. The import should be package:ecommerce_app/features/auth/presentation/cubit/auth_cubit.dart.

Also confirm you ran flutter pub get after updating pubspec.yaml. Missing dependencies cause import resolution failures.

help me fix Flutter test import errors in my ecommerce_app project

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Here is the full pubspec.yaml:

name: ecommerce_app
description: E-commerce Flutter app with Clean Architecture
environment:
  sdk: '>=3.0.0 <4.0.0'
dependencies:
  flutter:
    sdk: flutter
  get_it: ^7.6.0
  dio: ^5.4.0
  flutter_bloc: ^8.1.0
  socket_io_client: ^2.0.0
  firebase_core: ^2.24.0
  firebase_messaging: ^14.7.0
dev_dependencies:
  flutter_test:
    sdk: flutter
  bloc_test: ^9.1.0
  mocktail: ^1.0.0

Here is lib/core/network/dio_client.dart:

import 'package:dio/dio.dart';

class DioClient {
  final Dio dio;

  DioClient({required this.dio}) {
    dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) {
        final token = _getToken();
        if (token != null) {
          options.headers['Authorization'] = 'Bearer \$token';
        }
        handler.next(options);
      },
    ));
  }

  String? _getToken() => null; // Replaced by auth state
}

Here is lib/features/auth/domain/entities/user_entity.dart:

class UserEntity {
  final String id;
  final String name;
  final String email;
  final String token;

  const UserEntity({required this.id, required this.name, required this.email, required this.token});
}

Here is lib/features/auth/presentation/cubit/auth_cubit.dart:

import 'package:flutter_bloc/flutter_bloc.dart';

abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState { final String token; AuthAuthenticated(this.token); }
class AuthError extends AuthState { final String message; AuthError(this.message); }

class AuthCubit extends Cubit<AuthState> {
  AuthCubit() : super(AuthInitial());

  Future<void> login(String email, String password) async {
    emit(AuthLoading());
    // TODO: call repository
    emit(AuthAuthenticated('token'));
  }

  Future<void> register(String name, String email, String password) async {
    emit(AuthLoading());
    // TODO: call repository
    emit(AuthAuthenticated('token'));
  }
}

Here is lib/features/products/domain/entities/product_entity.dart:

class ProductEntity {
  final String id;
  final String name;
  final String description;
  final double price;
  final String category;
  final int stock;

  const ProductEntity({required this.id, required this.name, required this.description, required this.price, required this.category, required this.stock});
}

Here is lib/features/products/presentation/bloc/product_bloc.dart:

import 'package:flutter_bloc/flutter_bloc.dart';

abstract class ProductEvent {}
class LoadProducts extends ProductEvent {}
class AddProduct extends ProductEvent { final Map<String, dynamic> data; AddProduct(this.data); }
class UpdateProduct extends ProductEvent { final String id; final Map<String, dynamic> data; UpdateProduct(this.id, this.data); }
class DeleteProduct extends ProductEvent { final String id; DeleteProduct(this.id); }

abstract class ProductState {}
class ProductInitial extends ProductState {}
class ProductLoading extends ProductState {}
class ProductLoaded extends ProductState { final List<dynamic> products; ProductLoaded(this.products); }
class ProductError extends ProductState { final String message; ProductError(this.message); }

class ProductBloc extends Bloc<ProductEvent, ProductState> {
  ProductBloc() : super(ProductInitial()) {
    on<LoadProducts>((event, emit) async {
      emit(ProductLoading());
      // TODO: call repository
      emit(ProductLoaded([]));
    });
  }
}

Here is test/auth_cubit_test.dart:

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ecommerce_app/features/auth/presentation/cubit/auth_cubit.dart';

void main() {
  group('AuthCubit', () {
    blocTest<AuthCubit, AuthState>(
      'emits [AuthLoading, AuthAuthenticated] when login called',
      build: () => AuthCubit(),
      act: (cubit) => cubit.login('test@test.com', 'password'),
      expect: () => [isA<AuthLoading>(), isA<AuthAuthenticated>()],
    );
  });
}

Your Flutter app is structured with Clean Architecture and has working BLoC state management backed by passing unit tests. Next up, you will connect the Socket.io client for real-time product updates and integrate Firebase Cloud Messaging for push notifications.

Secret mission

Implement a Real-Time Price Alert System

Users want to know the instant a product drops below their budget. Build a price alert system that watches for price changes and fires targeted push notifications in real time.

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 via Docker containers, and the cloud services used (Firebase, Docker Hub, GitHub Actions) are all on free tiers with no ongoing costs.

Resources you used:

  • Docker containers (Node.js app, MongoDB, Redis, Nginx, RabbitMQ) and volumes running locally.
  • Local project files (backend and Flutter app).
  • Firebase project (free tier, no charges for FCM).
  • Docker Hub repository (free tier for public repos).
  • GitHub repository with Actions workflow (free for public repos).

Keep everything running

No action needed. Choose this if you're still actively building or testing your e-commerce notification system.

  • Your Docker containers continue running in the background.
  • Firebase, Docker Hub, and GitHub remain active on their free tiers.
  • Your Flutter app can connect to the backend at any time.

Pause - I'll come back to this later

Stop running containers to free up system memory and CPU without deleting your data.

  • Stop all Docker containers by running this command from your project folder:
docker compose stop
  • Your MongoDB data and container configurations are preserved.
  • To resume later, run:
docker compose start
  • Firebase, Docker Hub, and GitHub require no action. They remain available on free tiers.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Remove all Docker containers and volumes by running:
docker compose down -v
  • Delete the local project files. On macOS or Linux:
cd ~/Desktop
rm -rf ecommerce-backend
  • On Windows:
cd ~\Desktop
rmdir /s /q ecommerce-backend
  • Delete the Flutter app folder. On macOS or Linux:
rm -rf flutter_app
  • On Windows:
rmdir /s /q flutter_app
  • Delete your Firebase project: open the Firebase Console, click the gear icon next to Project Overview, select Project Settings, scroll to the bottom of the General tab, and click Delete project. Enter your project ID to confirm.
  • Delete your Docker Hub repository: sign in to Docker Hub, navigate to Repositories, select your repository, go to the Settings tab, and click Delete repository. Enter the repository name to confirm.
  • Delete your GitHub repository: open your repository on GitHub, click Settings, scroll to the Danger Zone section, and click Delete this repository. Follow the prompts and type the repository name to confirm.

Nice Work!

Nice Work!

You just built a production-grade full-stack mobile application from the ground up. This project spans backend API development, containerized infrastructure, event-driven architecture, CI/CD automation, and a Flutter mobile client.

You've learned how to:

  • Build a Node.js REST API with Express.js 5, JWT authentication, MongoDB schemas, Redis caching with invalidation, and real-time WebSocket communication via Socket.io.
  • Containerize a multi-service backend with Docker Compose (Node.js, MongoDB, Redis, Nginx, RabbitMQ), configure an Nginx reverse proxy with rate limiting, and automate testing and deployment with a GitHub Actions CI/CD pipeline.
  • Build a Flutter mobile app with Clean Architecture, BLoC state management, dependency injection, and Firebase Cloud Messaging push notifications driven by a RabbitMQ event queue.
  • Secret Mission: Implement a real-time price alert system where users set target prices and receive targeted push notifications when prices drop below their threshold.

Ready to quiz yourself?