58.6 Project 22: Node.js + MongoDB + Redis sessions + nginx load balancing, deployed to a VM
Why: the problem
Suppose we work at Zomato on a small internal notes app for delivery partners. On day one we run one Node.js container. It works. Then traffic grows and one container uses 100 percent CPU. We start three copies. Now a new problem comes: a partner logs in on copy 1, the next click goes to copy 2, and copy 2 says "please log in". The login was stored in the memory of copy 1. The fix is simple. We keep sessions in Redis, a shared store. Every copy reads the same session, so it does not matter which copy answers. nginx sits in front. It receives every request on port 80 and shares the requests between the copies. This is load balancing. MongoDB keeps the notes. We test on the laptop with Compose, then copy the same files to a VM and run the same commands. No Kubernetes needed.
What: every term in plain English
- Load balancer — A program that receives requests and spreads them over many copies of an app. Here it is nginx.
- Upstream — The nginx block that lists the app servers behind it.
- Session — Small data that remembers who you are between clicks, found with a cookie.
- Sticky session — Sending one user always to the same copy. We avoid it by keeping sessions in Redis.
- connect-redis — The Express session store that saves sessions in Redis.
- --scale — docker compose up --scale app=3 runs three copies of one service.
- Docker DNS round robin — The name app returns all copy IPs, so nginx can find every copy.
- VM — A virtual machine such as an EC2 instance. We run Docker and Compose on it.
How: step by step
- Write the Node app. Express with login, notes in MongoDB and sessions in Redis.
- Write the nginx config. One upstream that points to the service name app.
- Run with Compose and scale. Three app copies behind nginx on port 80.
- Prove the session is shared. Log in once, then many requests hit different copies but stay logged in.
- Deploy to a VM. Copy the folder with scp and run the same compose commands.
- Clean up. down -v locally and terminate the VM.
Architecture
Folder layout
notes/
compose.yaml
.env.example (copy to .env, never commit .env)
app/package.json app/server.js app/Dockerfile app/.dockerignore
nginx/default.conf
ops/session-test.sh ops/deploy-vm.sh ops/cleanup.sh
Example 1: The app: Express, MongoDB for notes, Redis for sessions
Goal. The app: Express, MongoDB for notes, Redis for sessions. Each reply says which copy answered.
app/package.json
{
"name": "notes",
"version": "1.0.0",
"main": "server.js",
"scripts": { "start": "node server.js" },
"dependencies": {
"express": "4.21.1",
"express-session": "1.18.1",
"connect-redis": "7.1.1",
"redis": "4.7.0",
"mongodb": "6.10.0"
}
}
app/server.js
const express = require("express");
const session = require("express-session");
const RedisStore = require("connect-redis").default;
const { createClient } = require("redis");
const { MongoClient } = require("mongodb");
const os = require("os");
const ME = os.hostname();
const redis = createClient({ url: process.env.REDIS_URL });
const mongo = new MongoClient(process.env.MONGO_URL);
async function main() {
await redis.connect();
await mongo.connect();
const notes = mongo.db("notes").collection("notes");
const app = express();
app.use(express.json());
app.set("trust proxy", 1);
app.use(session({
store: new RedisStore({ client: redis, prefix: "sess:" }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: "lax", maxAge: 3600000 }
}));
app.get("/healthz", async (req, res) => {
await redis.ping();
await mongo.db("admin").command({ ping: 1 });
res.send("ok");
});
app.get("/whoami", (req, res) => {
res.json({ servedBy: ME, user: req.session.user || null });
});
app.post("/login", (req, res) => {
const name = (req.body.name || "").trim();
if (!name) return res.status(400).json({ error: "name required" });
req.session.user = name;
res.json({ servedBy: ME, loggedIn: name });
});
app.post("/logout", (req, res) => {
req.session.destroy(() => res.json({ servedBy: ME, loggedOut: true }));
});
app.post("/notes", async (req, res) => {
if (!req.session.user) return res.status(401).json({ error: "login first" });
const doc = { owner: req.session.user, text: req.body.text, at: new Date() };
await notes.insertOne(doc);
res.status(201).json({ servedBy: ME, saved: doc.text });
});
app.get("/notes", async (req, res) => {
if (!req.session.user) return res.status(401).json({ error: "login first" });
const list = await notes.find({ owner: req.session.user }).sort({ at: -1 }).limit(20).toArray();
res.json({ servedBy: ME, notes: list.map(n => n.text) });
});
process.on("SIGTERM", async () => {
console.log(ME, "stopping");
await redis.quit();
await mongo.close();
process.exit(0);
});
app.listen(3000, () => console.log(ME, "listening on 3000"));
}
main().catch(e => { console.error(e); process.exit(1); });
app/Dockerfile
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json .
RUN npm install --omit=dev && npm cache clean --force
COPY server.js .
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --retries=3 CMD wget -qO- http://localhost:3000/healthz || exit 1
CMD ["node", "server.js"]
app/.dockerignore
node_modules
npm-debug.log
.env
What each line does
servedBy: ME— Each copy prints its container hostname. This is how we see the load balancer working.RedisStore— Sessions live in Redis with the prefix sess:, not in the memory of one copy.trust proxy 1— Express trusts nginx in front, so secure cookies and client IPs work behind the proxy.SESSION_SECRET— Signs the cookie. It comes from an env file, never from the code.HEALTHCHECK— Docker marks a copy unhealthy if Redis or MongoDB cannot be reached.SIGTERM handler— On docker stop the copy closes its connections cleanly.
Green output
$ docker build -t notes-app ./app
=> naming to docker.io/library/notes-app
$ docker image ls notes-app --format '{{.Repository}} {{.Size}}'
notes-app 142MB
Expected result. The app image is built. Next we add nginx, MongoDB and Redis around it.
Example 2: nginx and the compose file: one nginx, many app copies, shared Redis and MongoDB
Goal. nginx and the compose file: one nginx, many app copies, shared Redis and MongoDB.
nginx/default.conf
upstream notes_app {
server app:3000 max_fails=2 fail_timeout=10s;
keepalive 16;
}
server {
listen 80;
server_name _;
resolver 127.0.0.11 valid=10s;
add_header X-Upstream $upstream_addr always;
location / {
proxy_pass http://notes_app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_next_upstream error timeout http_502 http_503;
}
location = /nginx-health {
access_log off;
return 200 "ok";
}
}
compose.yaml
services:
mongo:
image: mongo:7.0
environment:
MONGO_INITDB_ROOT_USERNAME: notes
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
volumes: [mongo-data:/data/db]
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
retries: 10
redis:
image: redis:7.4-alpine
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
volumes: [redis-data:/data]
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
retries: 10
app:
build: ./app
image: notes-app:1.0
environment:
MONGO_URL: mongodb://notes:${MONGO_PASSWORD}@mongo:27017/?authSource=admin
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
SESSION_SECRET: ${SESSION_SECRET}
depends_on:
mongo: { condition: service_healthy }
redis: { condition: service_healthy }
restart: unless-stopped
deploy:
resources:
limits: { cpus: "0.50", memory: 256M }
nginx:
image: nginx:1.27-alpine
ports: ["80:80"]
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: [app]
restart: unless-stopped
volumes:
mongo-data:
redis-data:
.env.example
MONGO_PASSWORD=change-me-mongo
REDIS_PASSWORD=change-me-redis
SESSION_SECRET=change-me-long-random-string
What each line does
server app:3000— nginx asks Docker DNS for app. With 3 copies, the name has 3 IPs and nginx uses all of them, one by one.max_fails / fail_timeout— If a copy fails twice, nginx skips it for 10 seconds.proxy_next_upstream— If one copy gives 502 or 503, nginx tries the next copy. The user does not see the error.X-Upstream header— Shows which copy answered. Good for learning; remove it in production.No ports on app— Only nginx is published. The app copies are reachable only inside the network, so --scale does not fight for port 3000.${MONGO_PASSWORD}— Compose reads it from .env. Copy .env.example to .env and change the values. Never commit .env.
Green output
$ cp .env.example .env
$ docker compose up -d --build --scale app=3
Container notes-mongo-1 Healthy
Container notes-redis-1 Healthy
Container notes-app-1 Started
Container notes-app-2 Started
Container notes-app-3 Started
Container notes-nginx-1 Started
$ for i in 1 2 3 4 5 6; do curl -s localhost/whoami; echo; done
{"servedBy":"5c1d2a7e90b1","user":null}
{"servedBy":"a83f6e1c22d4","user":null}
{"servedBy":"0e9b7d3f4c18","user":null}
{"servedBy":"5c1d2a7e90b1","user":null}
Expected result. Requests rotate over three copies. This is round robin load balancing.
Example 3: Prove sessions are shared, kill one copy, and deploy the same stack to a VM
Goal. Prove sessions are shared, kill one copy, and deploy the same stack to a VM.
ops/session-test.sh
set -e
J=cookies.txt
rm -f "$J"
curl -s -c "$J" -H 'Content-Type: application/json' -d '{"name":"asha"}' localhost/login; echo
for i in 1 2 3 4 5 6; do
curl -s -b "$J" localhost/whoami; echo
done
curl -s -b "$J" -H 'Content-Type: application/json' -d '{"text":"pick up order 4521"}' localhost/notes; echo
curl -s -b "$J" localhost/notes; echo
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'sess:*'
ops/deploy-vm.sh
set -e
VM=ubuntu@203.0.113.10
KEY=~/.ssh/notes-demo.pem
ssh -i "$KEY" "$VM" "mkdir -p ~/notes"
scp -i "$KEY" -r compose.yaml app nginx .env "$VM":~/notes/
ssh -i "$KEY" "$VM" "cd ~/notes && docker compose up -d --build --scale app=3 && docker compose ps"
ops/cleanup.sh
docker compose down -v --rmi local
rm -f cookies.txt
echo "local stack removed"
What each line does
-c and -b cookies.txt— curl saves the session cookie and sends it back, like a browser.Different servedBy, same user— The session is found in Redis by every copy. Shared state, no sticky sessions.KEYS sess:*— Shows the session saved in Redis. Use KEYS only on test data; use SCAN on big servers.203.0.113.10— A documentation example IP. Use your EC2 public IP. Open only port 80 (and 22 from your IP) in the security group.scp .env— The secrets file goes to the VM over SSH. In a real team use SSM Parameter Store instead.Same commands on the VM— Compose files are portable. What worked on the laptop works on the VM.
Green output
$ REDIS_PASSWORD=change-me-redis bash ops/session-test.sh
{"servedBy":"5c1d2a7e90b1","loggedIn":"asha"}
{"servedBy":"a83f6e1c22d4","user":"asha"}
{"servedBy":"0e9b7d3f4c18","user":"asha"}
{"servedBy":"5c1d2a7e90b1","user":"asha"}
{"servedBy":"a83f6e1c22d4","saved":"pick up order 4521"}
{"servedBy":"0e9b7d3f4c18","notes":["pick up order 4521"]}
1) "sess:Xb3kP0q9..."
$ docker kill notes-app-2
$ for i in 1 2 3 4; do curl -s -o /dev/null -w '%{http_code} ' localhost/whoami; done
200 200 200 200
$ docker compose up -d --scale app=3
Container notes-app-2 Started
Expected result. Login stays across copies, a killed copy causes no error for users, and restart brings it back.
Every command, step by step (plain docker and Compose)
-
Copy the env file
cp .env.example .envWhat it does: Creates .env with placeholder secrets. Change them.
(no output) -
Build and start 3 copies
docker compose up -d --build --scale app=3What it does: Starts Mongo, Redis, 3 app copies and nginx.
Container notes-app-3 Started -
Check status
docker compose ps --format '{{.Name}} {{.Status}}'What it does: All app copies should be healthy.
notes-app-1 Up 30 seconds (healthy) -
See round robin
for i in 1 2 3 4 5 6; do curl -s localhost/whoami; echo; doneWhat it does: servedBy changes on each request.
{"servedBy":"a83f6e1c22d4","user":null} -
See the upstream header
curl -sI localhost/whoami | grep -i x-upstreamWhat it does: Shows the IP of the copy that answered.
X-Upstream: 172.20.0.5:3000 -
Log in and test sessions
REDIS_PASSWORD=change-me-redis bash ops/session-test.shWhat it does: Same user on every copy.
{"servedBy":"0e9b7d3f4c18","user":"asha"} -
Kill one copy
docker kill notes-app-2What it does: nginx skips the dead copy.
notes-app-2 -
Check no errors
for i in 1 2 3 4; do curl -s -o /dev/null -w '%{http_code} ' localhost/whoami; doneWhat it does: All 200 thanks to proxy_next_upstream.
200 200 200 200 -
Scale to 5
docker compose up -d --scale app=5What it does: More copies; reload nginx if a new copy is not used.
Container notes-app-5 Started -
Reload nginx
docker compose exec nginx nginx -s reloadWhat it does: nginx looks up the app IPs again.
signal process started -
Watch logs
docker compose logs -f --tail 5 appWhat it does: Logs from all copies with prefixes.
app-1 | 5c1d2a7e90b1 listening on 3000 -
Deploy to a VM
bash ops/deploy-vm.shWhat it does: Same stack on EC2.
notes-nginx-1 Up -
Clean up
bash ops/cleanup.shWhat it does: Removes containers, volumes and the local image.
local stack removed
When it is red 1
Error response from daemon: driver failed programming external connectivity: Bind for 0.0.0.0:3000 failed: port is already allocated
Cause. You published ports: 3000:3000 on the app service and then used --scale. Only one copy can own a host port.
Fix, step by step
- Remove ports from the app service. Only nginx publishes a port.
- Use expose: 3000 if you want to document it.
When it is red 2
After login, every second request says user null
Cause. Sessions are stored in memory (the default MemoryStore), so only one copy knows the user.
Fix, step by step
- Use RedisStore as shown.
- Check REDIS_URL is the same on all copies.
When it is red 3
nginx: [emerg] host not found in upstream "app:3000"
Cause. nginx started before any app copy existed, or the service name is wrong.
Fix, step by step
- Keep depends_on: [app].
- Check the service is called app in compose.yaml and both are on the same network.
When it is red 4
New copies after --scale app=5 never get traffic
Cause. nginx resolved app once at start and kept the old list of IPs.
Fix, step by step
- Run docker compose exec nginx nginx -s reload after scaling.
- Or restart nginx: docker compose restart nginx.
When it is red 5
MongoServerError: Authentication failed.
Cause. The password in .env changed after the mongo-data volume was first created. Mongo only reads the root password on first start.
Fix, step by step
- For a test stack: docker compose down -v and start again.
- For real data: change the password inside Mongo with mongosh.
When it is red 6
502 Bad Gateway from nginx
Cause. All app copies are down or still starting.
Fix, step by step
- docker compose ps app and docker compose logs app.
- Check Mongo and Redis are healthy first.
Practice tasks
Easy Task 1: Start
Why: Compose.
What to do: Start the stack with 3 app copies and check health.
- Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
- Create the notes folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker compose up -d --build --scale app=3Easy Task 2: Round robin
Why: nginx.
What to do: Call /whoami 6 times and count each servedBy.
- Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
- Create the notes folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
curl -s localhost/whoamiEasy Task 3: Session
Why: Redis.
What to do: Log in once and call /whoami with the cookie 6 times.
- Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
- Create the notes folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
bash ops/session-test.shMedium Task 4: Break it
Why: Sessions.
What to do: Remove the store line, rebuild, and see logins get lost. Then put it back.
- Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
- Create the notes folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker compose up -d --build --scale app=3Medium Task 5: Kill a copy
Why: Self-heal.
What to do: Kill one copy and prove users get only 200s.
- Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
- Create the notes folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker kill notes-app-1Medium Task 6: Scale up
Why: Scale.
What to do: Scale to 5 and reload nginx. Check 5 different servedBy values.
- Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
- Create the notes folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker compose exec nginx nginx -s reloadMedium Task 7: least_conn
Why: nginx.
What to do: Change the upstream to least_conn and reload.
- Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
- Create the notes folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
least_conn;Hard Task 8: VM
Why: Deploy.
What to do: Deploy to a t3.micro EC2 with ops/deploy-vm.sh and open port 80 only.
- Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
- Create the notes folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
bash ops/deploy-vm.shHard Task 9: Rate limit
Why: nginx.
What to do: Add limit_req_zone with 10r/s and test with a loop of 50 requests.
- Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
- Create the notes folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
limit_req zone=one burst=5;Easy Task 10: Clean
Why: Habit.
What to do: Remove the local stack and terminate the EC2 instance.
- Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
- Create the notes folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
bash ops/cleanup.shHomework: try at home
Add a second nginx strategy: change the upstream to least_conn and compare with round robin under load using hey or ab. Then add a rate limit of 10 requests per second per IP with limit_req_zone.
Recap
- Scaling an app needs shared state; keep sessions in Redis, data in MongoDB.
- Only nginx publishes a port; app copies stay private, so --scale works.
- nginx upstream with the service name uses Docker DNS to reach every copy.
- proxy_next_upstream hides a dead copy from users.
- Reload nginx after you change the number of copies.
- The same compose files run on a laptop and on a VM.
FAQ
Q. Is nginx enough, or do I need an AWS load balancer?
On one VM nginx is enough. With many VMs, put an AWS ALB in front of them.
Q. Why not sticky sessions?
Sticky sessions lose users when a copy dies and spread load badly. Shared sessions are simpler and safer.
Q. Can I use MongoDB for sessions too?
Yes with connect-mongo, but Redis is faster for small keys that change often.
Q. How many copies should I run?
Start with the number of CPU cores. Watch docker stats and response times.
Q. Can Compose do zero downtime deploys?
Partly: scale up new copies, reload nginx, then remove old ones. Swarm (Project 30) does rolling updates for you.
Q. Where does HTTPS go?
At nginx or Traefik in front. Project 23 shows automatic HTTPS with Traefik.
Interview questions
-
How do you load balance containers on one host?
Model answer: Run many copies with --scale and put nginx or Traefik in front with the service name as upstream.
-
Why does a stateless app matter for scaling?
Model answer: Any copy can answer any request, so copies can be added or killed freely.
-
Where do you keep sessions in a scaled app?
Model answer: In a shared store such as Redis or a database, not in process memory.
-
What is the difference between round robin and least_conn?
Model answer: Round robin takes turns; least_conn picks the copy with the fewest open connections.
-
How would you deploy a Compose app to a VM?
Model answer: Copy files over SSH or pull from a registry, keep secrets outside the repo, run docker compose up -d.
Quick quiz
Q1. Why do sessions break when we scale an app?
Each copy keeps its own memory. Shared sessions in Redis fix it.
Q2. Why must app copies not publish a host port?
Only one container can bind a host port. nginx is the single entry point.
Q3. What does proxy_next_upstream do?
Retries the request on another copy when one fails.
Q4. How does nginx find the copies?
Docker DNS returns all copy IPs for the service name app.
Q5. What changes when we move to a VM?
Nothing in the files. We copy them and run the same compose commands.
Samajla ka?
Revision card
Interview card