Ravindra BagaleCourses & study guides मराठी

Chapter 58: Docker Projects Part 2: AWS RDS, DynamoDB, S3, Reel Upload, Microservices, Queues, Logging, Monitoring and Cloud Deploys

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.
Write the Node app. Write the nginx config. Run with Compose and scale. Prove the session is shared. Deploy to a VM. Clean up.
nginx shares requests over 3 Node.js copies; sessions live in Redis, notes in MongoDB

How: step by step

  1. Write the Node app. Express with login, notes in MongoDB and sessions in Redis.
  2. Write the nginx config. One upstream that points to the service name app.
  3. Run with Compose and scale. Three app copies behind nginx on port 80.
  4. Prove the session is shared. Log in once, then many requests hit different copies but stay logged in.
  5. Deploy to a VM. Copy the folder with scp and run the same compose commands.
  6. Clean up. down -v locally and terminate the VM.

Architecture

Browser or phone http://VM-IP (port 80) nginx container upstream notes_app (round robin) app-1 app-2 app-3 Node.js + Express Redis shared sessions sess:* MongoDB notes collection, volume mongo-data
Load-balanced Node app with shared sessions

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)

  1. Copy the env file

    cp .env.example .env
    

    What it does: Creates .env with placeholder secrets. Change them.

    (no output)
    
  2. Build and start 3 copies

    docker compose up -d --build --scale app=3
    

    What it does: Starts Mongo, Redis, 3 app copies and nginx.

    Container notes-app-3 Started
    
  3. Check status

    docker compose ps --format '{{.Name}} {{.Status}}'
    

    What it does: All app copies should be healthy.

    notes-app-1 Up 30 seconds (healthy)
    
  4. See round robin

    for i in 1 2 3 4 5 6; do curl -s localhost/whoami; echo; done
    

    What it does: servedBy changes on each request.

    {"servedBy":"a83f6e1c22d4","user":null}
    
  5. See the upstream header

    curl -sI localhost/whoami | grep -i x-upstream
    

    What it does: Shows the IP of the copy that answered.

    X-Upstream: 172.20.0.5:3000
    
  6. Log in and test sessions

    REDIS_PASSWORD=change-me-redis bash ops/session-test.sh
    

    What it does: Same user on every copy.

    {"servedBy":"0e9b7d3f4c18","user":"asha"}
    
  7. Kill one copy

    docker kill notes-app-2
    

    What it does: nginx skips the dead copy.

    notes-app-2
    
  8. Check no errors

    for i in 1 2 3 4; do curl -s -o /dev/null -w '%{http_code} ' localhost/whoami; done
    

    What it does: All 200 thanks to proxy_next_upstream.

    200 200 200 200
    
  9. Scale to 5

    docker compose up -d --scale app=5
    

    What it does: More copies; reload nginx if a new copy is not used.

    Container notes-app-5 Started
    
  10. Reload nginx

    docker compose exec nginx nginx -s reload
    

    What it does: nginx looks up the app IPs again.

    signal process started
    
  11. Watch logs

    docker compose logs -f --tail 5 app
    

    What it does: Logs from all copies with prefixes.

    app-1  | 5c1d2a7e90b1 listening on 3000
    
  12. Deploy to a VM

    bash ops/deploy-vm.sh
    

    What it does: Same stack on EC2.

    notes-nginx-1 Up
    
  13. Clean up

    bash ops/cleanup.sh
    

    What 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

  1. Remove ports from the app service. Only nginx publishes a port.
  2. 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

  1. Use RedisStore as shown.
  2. 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

  1. Keep depends_on: [app].
  2. 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

  1. Run docker compose exec nginx nginx -s reload after scaling.
  2. 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

  1. For a test stack: docker compose down -v and start again.
  2. 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

  1. docker compose ps app and docker compose logs app.
  2. 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.

How:
  1. Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
  2. Create the notes folder with the files from this lesson.
  3. Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker compose up -d --build --scale app=3

Easy Task 2: Round robin

Why: nginx.

What to do: Call /whoami 6 times and count each servedBy.

How:
  1. Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
  2. Create the notes folder with the files from this lesson.
  3. Run the steps and compare with the solution. Always run the cleanup step.
Solution
curl -s localhost/whoami

Easy Task 3: Session

Why: Redis.

What to do: Log in once and call /whoami with the cookie 6 times.

How:
  1. Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
  2. Create the notes folder with the files from this lesson.
  3. Run the steps and compare with the solution. Always run the cleanup step.
Solution
bash ops/session-test.sh

Medium Task 4: Break it

Why: Sessions.

What to do: Remove the store line, rebuild, and see logins get lost. Then put it back.

How:
  1. Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
  2. Create the notes folder with the files from this lesson.
  3. Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker compose up -d --build --scale app=3

Medium Task 5: Kill a copy

Why: Self-heal.

What to do: Kill one copy and prove users get only 200s.

How:
  1. Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
  2. Create the notes folder with the files from this lesson.
  3. Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker kill notes-app-1

Medium Task 6: Scale up

Why: Scale.

What to do: Scale to 5 and reload nginx. Check 5 different servedBy values.

How:
  1. Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
  2. Create the notes folder with the files from this lesson.
  3. Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker compose exec nginx nginx -s reload

Medium Task 7: least_conn

Why: nginx.

What to do: Change the upstream to least_conn and reload.

How:
  1. Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
  2. Create the notes folder with the files from this lesson.
  3. 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.

How:
  1. Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
  2. Create the notes folder with the files from this lesson.
  3. Run the steps and compare with the solution. Always run the cleanup step.
Solution
bash ops/deploy-vm.sh

Hard Task 9: Rate limit

Why: nginx.

What to do: Add limit_req_zone with 10r/s and test with a loop of 50 requests.

How:
  1. Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
  2. Create the notes folder with the files from this lesson.
  3. 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.

How:
  1. Use Docker Desktop locally, then one Ubuntu VM (EC2 t3.micro) with Docker.
  2. Create the notes folder with the files from this lesson.
  3. Run the steps and compare with the solution. Always run the cleanup step.
Solution
bash ops/cleanup.sh

Homework: 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

  1. 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.

  2. Why does a stateless app matter for scaling?

    Model answer: Any copy can answer any request, so copies can be added or killed freely.

  3. 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.

  4. 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.

  5. 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?

Ravi Sir's Cards for this lesson

मराठी cards