Beginner’s Guide — Build a Node.js Microservices Stack with Docker, MongoDB & RabbitMQ

Software Engineer (SWE) specializing in Frontend development, proficient in JavaScript, React, Next.js, Angular, and a variety of frontend tools. Also skilled in MERN Stack. Committed to crafting clean, efficient code and driving innovation in every project. Passionate about collaborating with dynamic teams to create impactful solutions and continuously advance in the field of frontend development.
Want to learn how to put together a small microservices system using Node.js services, MongoDB for persistence, and RabbitMQ as a message broker for notifications? Perfect — this guide walks you through everything: the full code, what each line does, why and when to use RabbitMQ, how to run it with Docker Compose.
Project overview (what you’re building)
A simple microservice system with:
user-service — CRUD for users (MongoDB
usersDB). HTTP API on port 3000.task-service — CRUD for tasks (MongoDB
tasksDB). When a task is created it publishes a message to RabbitMQ queuetask_created. HTTP API on port 3001.notification-service — Listens to the
task_createdqueue and logs notifications (simulates sending emails/push). No HTTP needed (runs on port 3002 but only prints).mongo — MongoDB container, data persisted to a named Docker volume.
rabbitmq — RabbitMQ (with management UI) to broker messages between services.
You control all with a single docker-compose.yml.
Full docker-compose.yml
services:
mongo:
image: mongodb/mongodb-community-server:latest
container_name: mongo
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
rabbitmq:
image: rabbitmq:3-management
container_name: rabbitmq
ports:
- "5672:5672"
- "15672:15672"
user-service:
build: ./user-service
container_name: user-service
ports:
- "3000:3000"
depends_on:
- mongo
task-service:
build: ./task-service
container_name: task-service
ports:
- "3001:3001"
depends_on:
- mongo
- rabbitmq
notification-service:
build: ./notification-service
container_name: notification-service
ports:
- "3002:3002"
depends_on:
- mongo
- rabbitmq
volumes:
mongo_data:
Explanation (line by line — docker-compose.yml)
services:— top level key, declares containers to run.mongo:— defines a service namedmongo.image: mongodb/mongodb-community-server:latest— uses official MongoDB community server image (latest tag). This will pull from Docker Hub.container_name: mongo— sets the container’s name tomongofor easierdockercommands.ports: - "27017:27017"— exposes MongoDB default port 27017 to the host (host:container). Useful for debugging withmongoCLI / GUI clients.volumes: - mongo_data:/data/db— persists DB files into a named volumemongo_dataso data survives container restarts.
rabbitmq:— defines RabbitMQ service.image: rabbitmq:3-management— RabbitMQ image that includes the management UI plugin (accessible on 15672).container_name: rabbitmq— container name.ports:"5672:5672"— RabbitMQ AMQP port (used by producers/consumers)."15672:15672"— management web UI (open in browser to monitor queues).
user-service:— our Node.js user service.build: ./user-service— build fromuser-servicefolder and itsDockerfile.container_name: user-service— container name.ports: - "3000:3000"— expose port 3000.depends_on: - mongo— Docker Compose will startmongobeforeuser-service. Important:depends_ononly controls start order; it does not wait for MongoDB to be fully ready — your code must handle retries or wait.
task-service:— Node service for tasks.depends_on: - mongo - rabbitmq— start mongo and rabbitmq first, but again readiness should be handled by the app (this code does a retry for RabbitMQ).
notification-service:— consumer that listens to RabbitMQ and prints notifications.depends_on:for bothmongoandrabbitmq.
volumes:mongo_data:— declares a named Docker volume that was referenced earlier.
user-service
user-service/Dockerfile
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD [ "node","index.js" ]
Explanation (line by line):
FROM node:22— base image containing Node.js v22. This includes Node and npm. Pick a specific version in production to avoid surprises.WORKDIR /app— sets working directory inside container to/app.COPY package*.json ./— copiespackage.jsonandpackage-lock.jsonif present (install dependencies first).RUN npm install— installs dependencies inside the image so we don't run install on container start.COPY . .— copy application code into container.EXPOSE 3000— documents that container listens on port 3000 (for humans and some tools).CMD [ "node","index.js" ]— default command when container starts; runindex.js.
user-service/index.js
const express = require("express");
const mongoose = require("mongoose");
const bodyParser = require("body-parser");
const app = express();
const port = 3000;
app.use(bodyParser.json());
mongoose
.connect("mongodb://mongo:27017/users")
.then(() => {
console.log("Connected to MongoDB");
})
.catch((error) => {
console.error("Error connecting to MongoDB:", error);
});
const UserSchema = new mongoose.Schema({
name: String,
email: String,
});
const User = mongoose.model("User", UserSchema);
app.get("/users", async (req, res) => {
const users = await User.find();
res.json(users);
});
app.post("/users", async (req, res) => {
const { name, email } = req.body;
try {
const user = new User({ name, email });
await user.save();
res.status(201).json(user);
} catch (error) {
console.error("Error Saving: ", error);
res.status(500).json({ error: "Internal Server Error" });
}
});
app.get("/", (req, res) => {
res.send("Hello World!");
});
app.listen(port, () => {
console.log(`User service listening on port ${port}`);
});
Line-by-line explanation — index.js:
const express = require("express");— import Express, a minimal web framework.const mongoose = require("mongoose");— import Mongoose, an ODM to talk to MongoDB.const bodyParser = require("body-parser");— parse JSON request bodies (Express >=4.16 hasexpress.json(), but herebody-parseris used).const app = express();— create Express app.const port = 3000;— port where service will listen.app.use(bodyParser.json());— middleware to parse incoming JSON payloads intoreq.body.mongoose.connect("mongodb://mongo:27017/users")— connect to MongoDB using hostnamemongo(Docker Compose DNS) and DBusers..then(() => console.log(...))— logs success..catch(...)— logs failure. Note: if Mongo isn't ready, this will fail once; in production you likely want a retry loop (or usemongoose.connect()options and retry logic).
const UserSchema = new mongoose.Schema({ name: String, email: String });— defines a simple schema for users withnameandemail.const User = mongoose.model("User", UserSchema);— creates a Mongoose model.app.get("/users", async (req, res) => { ... })— route to return all users as JSON.app.post("/users", async (req, res) => { ... })— route to create a new user fromreq.body. On success responds with201and the created user.app.get("/", (req, res) => res.send("Hello World!"));— root route for quick check.app.listen(port, () => console.log(...));— start the server when app starts.
user-service/package.json
{
"name": "user-service",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"body-parser": "^2.2.0",
"express": "^5.1.0",
"mongoose": "^8.19.3"
}
}
Notes: lists dependencies. In modern Node, you can use express.json() instead of body-parser, but this works fine.
task-service
task-service/Dockerfile
Same pattern as user-service:
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3001
CMD [ "node","index.js" ]
(Explained earlier — just exposes port 3001 and runs index.js.)
task-service/index.js
const express = require("express");
const mongoose = require("mongoose");
const bodyParser = require("body-parser");
const amqp = require("amqplib");
const app = express();
const port = 3001;
app.use(bodyParser.json());
mongoose
.connect("mongodb://mongo:27017/tasks")
.then(() => {
console.log("Connected to MongoDB");
})
.catch((error) => {
console.error("Error connecting to MongoDB:", error);
});
const TaskSchema = new mongoose.Schema({
title: String,
description: String,
userId: String,
createdAt: { type: Date, default: Date.now },
});
const Task = mongoose.model("Task", TaskSchema);
let channel, connection;
async function connectRabbitMQWithRetry(retries = 5, delay = 3000) {
while (retries) {
try {
connection = await amqp.connect("amqp://rabbitmq");
channel = await connection.createChannel();
await channel.assertQueue("task_created");
console.log("Connected to RabbitMQ");
return;
} catch (error) {
console.error("RabbitMQ Connection Error: ", error);
retries--;
console.error("Retrying again: ", retries);
await new Promise((res) => setTimeout(res, delay));
}
}
}
app.get("/tasks", async (req, res) => {
const tasks = await Task.find();
res.json(tasks);
});
app.post("/tasks", async (req, res) => {
const { title, description, userId } = req.body;
try {
const task = new Task({ title, description, userId });
await task.save();
const message = {
taskId: task._id,
userId,
title,
};
if (!channel) {
return res.status(503).json({ error: "RabbitMQ not connected" });
}
channel.sendToQueue("task_created", Buffer.from(JSON.stringify(message)));
res.status(201).json(task);
} catch (error) {
console.error("Error Saving: ", error);
res.status(500).json({ error: "Internal Server Error" });
}
});
app.listen(port, () => {
console.log(`Task service listening on port ${port}`);
connectRabbitMQWithRetry();
});
Line-by-line (key points):
Imports similar to user-service, plus
amqplibto talk to RabbitMQ using AMQP protocol.mongoose.connect("mongodb://mongo:27017/tasks")— connects totasksdatabase.TaskSchemaincludestitle,description,userId, andcreatedAt.let channel, connection;— will hold RabbitMQ connection and channel (channels are logical connections inside an AMQP connection).connectRabbitMQWithRetry(retries = 5, delay = 3000)— helper that tries to connect up to 5 times, waitingdelayms between attempts. This mitigates startup race conditions where RabbitMQ isn't ready when the container starts.connection = await amqp.connect("amqp://rabbitmq")— connects to hostrabbitmq(Compose DNS).channel = await connection.createChannel()— creates a channel.await channel.assertQueue("task_created")— asserts the queue exists (creates if not).
POST /tasks— creates a new Task, persists it, then:Builds
messageobject withtaskId,userId,title.If
channelis falsy (RabbitMQ not connected), returns503 Service Unavailable.channel.sendToQueue("task_created", Buffer.from(JSON.stringify(message)));— publishes the message to queue (note: default exchange, queue name used as routing key).
app.listen(...); connectRabbitMQWithRetry();— on startup the service starts listening and then attempts to connect to RabbitMQ.
Why this design? Worker/consumer decoupling — the service that creates tasks does not directly call notification system; instead it publishes an event and moves on. This enables scalability and resilience.
task-service/package.json
{
"name": "task-service",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"amqplib": "^0.10.9",
"body-parser": "^2.2.0",
"express": "^5.1.0",
"mongoose": "^8.19.3"
}
}
notification-service
notification-service/Dockerfile
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3002
CMD [ "node","index.js" ]
Same pattern. (Expose 3002 but this service doesn't necessarily need HTTP; okay for parity.)
notification-service/index.js
const amqp = require("amqplib");
async function start() {
try {
connection = await amqp.connect("amqp://rabbitmq");
channel = await connection.createChannel();
await channel.assertQueue("task_created");
console.log("Notification Service is listening to messages");
channel.consume("task_created", (msg) => {
const taskData = JSON.parse(msg.content.toString());
console.log("Notification: NEW TASK: ", taskData.title);
console.log("Notification: NEW TASK: ", taskData);
channel.ack(msg);
});
} catch (error) {
console.error("RabbitMQ Connection Error: ", error.message);
}
}
start();
Explanation:
Imports
amqplib.start()attempts to connect to RabbitMQ atamqp://rabbitmq.Creates a channel and
assertQueue("task_created")to ensure the queue exists.channel.consume("task_created", (msg) => { ... })— registers a consumer callback for messages arriving on the queue.Parses the message and logs it (your notification logic would go here: send email, push, etc.)
channel.ack(msg)— acknowledges the message so RabbitMQ removes it from the queue.
start();— run the consumer.
Note: This service has no retry loop. If RabbitMQ isn't ready at startup, it will fail once and quit — consider adding the same retry logic as task-service or a supervisor to restart the container. Docker Compose will try to restart depending on restart policy.
notification-service/package.json
{
"name": "notification-service",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"amqplib": "^0.10.9",
"body-parser": "^2.2.0",
"express": "^5.1.0",
"mongoose": "^8.19.3"
}
}
How to run locally (commands)
From the project root (where docker-compose.yml sits):
- Build and run in detached mode:
docker-compose up --build -d
- See logs:
docker-compose logs -f
Open RabbitMQ management UI:
http://localhost:15672/Default username/password for the base image are
guest/guestwhen accessed from the same host. (If running remote, change credentials.)There you can inspect the
task_createdqueue.
Test the API:
- Create a user:
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"name":"Alice","email":"alice@example.com"}'
- Create a task for that user:
curl -X POST http://localhost:3001/tasks -H "Content-Type: application/json" -d '{"title":"Buy milk","description":"From store","userId":"<userId>"}'
When you POST /tasks, the task-service will save the task and publish to RabbitMQ — the notification-service will consume the message and print the notification in its logs. Check the notification-service logs:
docker-compose logs -f notification-service
# or
docker logs -f notification-service
Why RabbitMQ? When and why use a message broker
What is a message broker?
A message broker (RabbitMQ, Kafka, Redis streams, etc.) enables asynchronous communication between services. Instead of calling the notification service directly, task-service posts a message describing an event to the broker. The notification service subscribes and reacts to these events.
Why use a broker (benefits)
Decoupling — Producer and consumer do not need to be running at the same time or know about each other’s HTTP API. They only agree on the message format and queue/exchange name.
Resilience — If the consumer is down, messages wait in the broker queue for it to come back.
Scalability — Multiple consumers can process messages in parallel (consumers scale horizontally).
Retry & DLQ — You can configure retries, dead-letter queues for failed messages.
Buffering/Load leveling — If a spike in tasks occurs, the queue buffers requests while consumers process them at their pace.
Flexibility — Add new consumers (e.g., analytics, audit logs) that subscribe to the same events without modifying producers.
When to use a message broker
When operations triggered by an event are not required to complete synchronously (e.g., sending email after creating task).
When you expect bursts of events and want to smooth processing.
When multiple independent systems should react to the same event (fan-out).
When you want to achieve loose coupling between services.
When not to use a message broker
For simple CRUD where synchronous response is required (e.g., login). Overusing a broker adds complexity.
If your operations must be atomic across services without compensation or two-phase commit (distributed transactions are hard).
Small projects / prototypes where REST calls are simpler — but often using a broker from beginning can pay off later.
Quick troubleshooting tips
If
task-servicesaysRabbitMQ not connected:Check RabbitMQ status:
docker-compose logs rabbitmqandhttp://localhost:15672(guest/guest).Ensure
task-serviceattempted to reconnect (see console logs).
If Mongo connection fails:
- Check
docker-compose logs mongoand ensure container started. Usedocker exec -it mongo bash+mongoshto test.
- Check
If messages aren't delivered:
- Open RabbitMQ management UI, look at queue lengths, bindings, and consumers.
If containers don’t start:
docker-compose psto inspect states;docker-compose logs <service>for errors.
Example flow — what happens when you create a task
Client sends
POST /taskstotask-servicewith{ title, description, userId }.task-servicesaves task to MongoDBtaskscollection.task-servicepublishes a JSON message to thetask_createdqueue via RabbitMQ.notification-service(consumer) receives the message and executes code to send a notification (currently logs to console).Consumer
acks the message so RabbitMQ removes it.
This split of responsibilities makes the architecture robust and easily extensible.
Final remarks
This example is a hands-on beginner-friendly microservice demo:
It shows how to persist data (MongoDB), expose HTTP APIs (Express), and decouple services using RabbitMQ.
The code is intentionally simple so you can focus on the architecture and messaging patterns.
Follow me on : Github Linkedin Threads Youtube Channel




