57.6 Project 6: Node.js with MySQL and with MongoDB, plus a Redis cache
Why: the problem
Suppose we are at Swiggy and a small team builds an orders API in Node.js. The restaurant team stores orders in MySQL because the data has a fixed shape. The offers team stores the same kind of data in MongoDB because each offer has different fields. Both teams read the same menu again and again, so the database gets the same query thousands of times per minute. Without Docker every developer installs Node 20, MySQL 8, MongoDB 7 and Redis on the laptop, and versions never match the server. With Docker we write one small Express app, one Dockerfile, and two Compose files: one for MySQL and one for MongoDB. Then we add Redis in front of the database as a cache, and we measure how much faster the second request is. By the end we have one Node image, two databases, one cache, and the habit of switching stacks with only environment variables.
What: every term in plain English
- Express — A small web framework for Node.js. It maps a URL like GET /orders to a function.
- mysql2 — The popular MySQL driver for Node.js. It supports promises and connection pools.
- mongodb driver — The official MongoDB driver for Node.js.
- Redis — An in-memory key-value store. We use it as a cache in front of the database.
- Cache-aside — The app first looks in Redis. On a miss it reads the database and saves the answer in Redis with a time limit (TTL).
- TTL — Time to live. Redis deletes the key after this many seconds.
- npm ci — Installs exactly what package-lock.json says. Faster and repeatable in Docker.
- Init script — A .sql or .js file in /docker-entrypoint-initdb.d. The database image runs it only on the first start with an empty volume.
How: step by step
- Write the Express app with two storage files. store-mysql.js and store-mongo.js.
- Write the Dockerfile. node:20-alpine, npm ci, non-root user node.
- Run with MySQL. compose.mysql.yaml with an init script.
- Run with MongoDB. compose.mongo.yaml with an init script.
- Add Redis as a cache. compose.cache.yaml merged on top.
- Test with curl and measure. First call is a miss, second is a hit.
- Clean up. down -v for each project.
Architecture
Folder layout
node-orders/
src/
server.js
store-mysql.js
store-mongo.js
cache.js
sql/mysql-init.sql
mongo/init.js
package.json
package-lock.json
Dockerfile
.dockerignore
.env
compose.mysql.yaml
compose.mongo.yaml
compose.cache.yaml
Example 1: The Node app, both storage files and the Dockerfile
Goal. The Node app, both storage files and the Dockerfile.
package.json
{
"name": "node-orders",
"version": "1.0.0",
"main": "src/server.js",
"scripts": { "start": "node src/server.js" },
"dependencies": {
"express": "4.21.1",
"mysql2": "3.11.3",
"mongodb": "6.10.0",
"redis": "4.7.0"
}
}
src/server.js
const express = require("express");
const kind = process.env.DB_KIND || "mysql";
const store = require(kind === "mongo" ? "./store-mongo" : "./store-mysql");
const cache = require("./cache");
const app = express();
app.use(express.json());
app.get("/healthz", async (req, res) => {
try {
await store.ping();
res.json({ status: "ok", db: kind, cache: cache.enabled });
} catch (e) {
res.status(503).json({ status: "down", error: e.message });
}
});
app.get("/menu", async (req, res) => {
const hit = await cache.get("menu");
if (hit) return res.json({ from: "cache", items: hit });
const items = await store.menu();
await cache.set("menu", items, 30);
res.json({ from: "db", items });
});
app.post("/orders", async (req, res) => {
const { item, qty } = req.body;
if (!item || !qty) return res.status(400).json({ error: "item and qty are required" });
res.status(201).json(await store.addOrder(item, Number(qty)));
});
app.get("/orders", async (req, res) => res.json(await store.orders()));
app.listen(3000, "0.0.0.0", () => console.log("orders api on 3000, db=" + kind));
src/store-mysql.js
const mysql = require("mysql2/promise");
const pool = mysql.createPool({
host: process.env.DB_HOST || "db",
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 5
});
exports.ping = async () => { await pool.query("SELECT 1"); };
exports.menu = async () => (await pool.query("SELECT name, price FROM menu ORDER BY name"))[0];
exports.orders = async () => (await pool.query("SELECT id, item, qty FROM orders ORDER BY id"))[0];
exports.addOrder = async (item, qty) => {
const [r] = await pool.query("INSERT INTO orders (item, qty) VALUES (?, ?)", [item, qty]);
return { id: r.insertId, item, qty };
};
src/store-mongo.js
const { MongoClient } = require("mongodb");
const client = new MongoClient(process.env.MONGO_URL);
const ready = client.connect();
const db = async () => { await ready; return client.db("swiggy"); };
exports.ping = async () => { await (await db()).command({ ping: 1 }); };
exports.menu = async () => (await db()).collection("menu").find({}, { projection: { _id: 0 } }).sort({ name: 1 }).toArray();
exports.orders = async () => (await db()).collection("orders").find({}, { projection: { _id: 0 } }).toArray();
exports.addOrder = async (item, qty) => {
const r = await (await db()).collection("orders").insertOne({ item, qty });
return { id: r.insertedId.toString(), item, qty };
};
src/cache.js
const { createClient } = require("redis");
const url = process.env.REDIS_URL;
let client = null;
if (url) {
client = createClient({ url });
client.on("error", (e) => console.log("redis error", e.message));
client.connect();
}
exports.enabled = Boolean(url);
exports.get = async (k) => (client ? JSON.parse((await client.get(k)) || "null") : null);
exports.set = async (k, v, ttl) => { if (client) await client.set(k, JSON.stringify(v), { EX: ttl }); };
Dockerfile
FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD wget -qO- http://localhost:3000/healthz || exit 1
CMD ["node", "src/server.js"]
.dockerignore
node_modules
.env
.git
*.log
What each line does
DB_KIND— Chooses which storage file is loaded at start-up.mysql.createPool— Keeps up to 5 open connections and reuses them.? placeholders— Values are sent separately from the SQL text, so SQL injection is not possible.cache.get / cache.set— Cache-aside: read Redis first, then the database, then save to Redis for 30 seconds.npm ci --omit=dev— Installs only production packages exactly as in package-lock.json.USER node— The node image already has a non-root user called node.HEALTHCHECK wget— Alpine has wget, so no extra package is needed for the health check.
Green output
$ npm install --package-lock-only
added 92 packages in 3s
$ docker build -t node-orders:1.0 .
=> [4/5] RUN npm ci --omit=dev
=> exporting to image
=> naming to docker.io/library/node-orders:1.0
$ docker image ls node-orders
REPOSITORY TAG IMAGE ID SIZE
node-orders 1.0 4b1e9c0a7d22 168MB
Expected result. One image of about 168 MB that can talk to MySQL or MongoDB, with an optional Redis cache.
Example 2: Run the Node app with MySQL
Goal. Run the Node app with MySQL.
sql/mysql-init.sql
CREATE TABLE menu (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
price INT NOT NULL
);
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
item VARCHAR(100) NOT NULL,
qty INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO menu (name, price) VALUES ('Masala Dosa', 120), ('Paneer Roll', 150), ('Veg Biryani', 220);
.env
MYSQL_ROOT_PASSWORD=change-me-root
DB_USER=orders
DB_PASSWORD=change-me-app
DB_NAME=orders
compose.mysql.yaml
services:
api:
build: .
image: node-orders:1.0
ports:
- "3000:3000"
environment:
DB_KIND: mysql
DB_HOST: db
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
- ./sql/mysql-init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s
retries: 20
volumes:
mysql-data:
What each line does
${DB_USER}— Compose reads the value from the .env file next to compose.mysql.yaml.docker-entrypoint-initdb.d— MySQL runs 01-init.sql once, only when mysql-data is empty.:ro— The container can read the init file but not change it.condition: service_healthy— The API starts only after mysqladmin ping succeeds.restart: unless-stopped— If Node crashes, Docker starts it again.
Green output
$ docker compose -f compose.mysql.yaml -p orders-mysql up -d --wait
Container orders-mysql-db-1 Healthy
Container orders-mysql-api-1 Healthy
$ curl -s localhost:3000/healthz
{"status":"ok","db":"mysql","cache":false}
$ curl -s -X POST localhost:3000/orders -H 'Content-Type: application/json' -d '{"item":"Masala Dosa","qty":2}'
{"id":1,"item":"Masala Dosa","qty":2}
$ curl -s localhost:3000/menu
{"from":"db","items":[{"name":"Masala Dosa","price":120},{"name":"Paneer Roll","price":150},{"name":"Veg Biryani","price":220}]}
Expected result. The API answers from MySQL. The menu comes from the init script, and orders are saved in the mysql-data volume.
Example 3: Run the same image with MongoDB
Goal. Run the same image with MongoDB.
mongo/init.js
db = db.getSiblingDB("swiggy");
db.menu.insertMany([
{ name: "Masala Dosa", price: 120 },
{ name: "Paneer Roll", price: 150 },
{ name: "Veg Biryani", price: 220, spicy: true }
]);
db.orders.createIndex({ item: 1 });
compose.mongo.yaml
services:
api:
build: .
image: node-orders:1.0
ports:
- "3000:3000"
environment:
DB_KIND: mongo
MONGO_URL: mongodb://root:${MONGO_PASSWORD}@mongo:27017/?authSource=admin
depends_on:
mongo:
condition: service_healthy
mongo:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
volumes:
- mongo-data:/data/db
- ./mongo/init.js:/docker-entrypoint-initdb.d/init.js:ro
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
retries: 20
volumes:
mongo-data:
What each line does
MONGO_URL— The full connection string. authSource=admin is needed because root lives in the admin database.getSiblingDB— Switches the init script to the swiggy database.spicy: true— Only one document has this field. MongoDB allows different shapes.createIndex— Makes searches by item fast.mongosh --eval— The health check asks Mongo to answer ping.
Green output
$ echo 'MONGO_PASSWORD=change-me-mongo' >> .env
$ docker compose -f compose.mongo.yaml -p orders-mongo up -d --wait
Container orders-mongo-mongo-1 Healthy
Container orders-mongo-api-1 Healthy
$ curl -s localhost:3000/healthz
{"status":"ok","db":"mongo","cache":false}
$ curl -s -X POST localhost:3000/orders -H 'Content-Type: application/json' -d '{"item":"Paneer Roll","qty":1}'
{"id":"6718f2c4a1b2c3d4e5f60718","item":"Paneer Roll","qty":1}
Expected result. Same image, no rebuild. Only DB_KIND and MONGO_URL changed.
Example 4: Add Redis as a cache and measure the speed
Goal. Add Redis as a cache and measure the speed.
compose.cache.yaml
services:
api:
environment:
REDIS_URL: redis://redis:6379
depends_on:
redis:
condition: service_healthy
redis:
image: redis:7-alpine
command: ["redis-server", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 10
What each line does
-f a -f b— Compose merges the second file on top of the first. The api service gets REDIS_URL added.--maxmemory 64mb— Redis never uses more than 64 MB.allkeys-lru— When full, Redis removes the least recently used keys first.redis-cli ping— Answers PONG when Redis is ready.TTL menu— Shows how many seconds are left before the cached menu expires.
Green output
$ docker compose -f compose.mysql.yaml -f compose.cache.yaml -p orders-mysql up -d --wait
Container orders-mysql-redis-1 Healthy
Container orders-mysql-api-1 Healthy
$ curl -s -w ' %{time_total}s\n' localhost:3000/menu | tail -c 40
...{"name":"Veg Biryani","price":220}]} 0.0182s
$ curl -s -w ' %{time_total}s\n' localhost:3000/menu | cut -c1-20
{"from":"cache","ite 0.0031s
$ docker compose -p orders-mysql exec redis redis-cli TTL menu
(integer) 24
Expected result. The first call reads MySQL (from db). The second comes from Redis (from cache) and is about 6 times faster.
Every command, step by step (plain docker and Compose)
-
Make folders
mkdir -p node-orders/src node-orders/sql node-orders/mongo && cd node-ordersWhat it does: Creates the project folders. Then add every file from the examples.
(no output) -
Create the lock file
docker run --rm -v "$PWD":/app -w /app node:20-alpine npm install --package-lock-onlyWhat it does: Makes package-lock.json without installing Node on the laptop.
added 92 packages in 3s -
Build the image
docker build -t node-orders:1.0 .What it does: Builds the Node image once for both stacks.
naming to docker.io/library/node-orders:1.0 -
Check the user
docker run --rm node-orders:1.0 whoamiWhat it does: Must print node, not root.
node -
Start the MySQL stack
docker compose -f compose.mysql.yaml -p orders-mysql up -d --waitWhat it does: Starts MySQL, waits for healthy, then the API.
Container orders-mysql-api-1 Healthy -
See the containers
docker compose -p orders-mysql psWhat it does: Shows both services and their health.
NAME STATUS orders-mysql-api-1 Up 20 seconds (healthy) orders-mysql-db-1 Up 31 seconds (healthy) -
Read the logs
docker compose -p orders-mysql logs api --tail 5What it does: Shows the start message from Node.
api-1 | orders api on 3000, db=mysql -
Look inside MySQL
docker compose -p orders-mysql exec db mysql -uorders -pchange-me-app orders -e 'SELECT * FROM orders'What it does: Proves the order row is really in the database.
id item qty created_at 1 Masala Dosa 2 2026-10-25 10:12:03 -
Add the cache
docker compose -f compose.mysql.yaml -f compose.cache.yaml -p orders-mysql up -d --waitWhat it does: Adds Redis and recreates the API with REDIS_URL.
Container orders-mysql-redis-1 Healthy -
Stop MySQL and start Mongo
docker compose -f compose.mysql.yaml -f compose.cache.yaml -p orders-mysql down docker compose -f compose.mongo.yaml -p orders-mongo up -d --waitWhat it does: Frees port 3000 and runs the same image on MongoDB.
Container orders-mongo-api-1 Healthy -
Look inside Mongo
docker compose -p orders-mongo exec mongo mongosh -u root -p change-me-mongo --quiet --eval 'db.getSiblingDB("swiggy").orders.find()'What it does: Shows the saved order documents.
[ { _id: ObjectId('6718f2c4...'), item: 'Paneer Roll', qty: 1 } ] -
Clean up
docker compose -f compose.mongo.yaml -p orders-mongo down -v docker compose -f compose.mysql.yaml -f compose.cache.yaml -p orders-mysql down -v docker image rm node-orders:1.0What it does: Removes both stacks, their volumes and the image.
Volume orders-mysql_mysql-data Removed Untagged: node-orders:1.0
When it is red 1
Error: connect ECONNREFUSED 172.20.0.2:3306
Cause. The API started before MySQL was ready, or depends_on has no condition.
Fix, step by step
- Add condition: service_healthy under depends_on.
- Check docker compose ps shows db as healthy.
- Run docker compose up -d again.
When it is red 2
Error: Access denied for user 'orders'@'172.20.0.3' (using password: YES)
Cause. The password in .env was changed after the volume was first created. MySQL keeps the old user.
Fix, step by step
- Practice data only: docker compose -p orders-mysql down -v.
- Start again so the init runs with the new password.
- On real data, change the password with ALTER USER instead.
When it is red 3
Error: Table 'orders.menu' doesn't exist
Cause. The init script did not run because the volume already had data from an older run.
Fix, step by step
- Run docker compose -p orders-mysql down -v on practice data.
- Check the file is mounted: docker compose exec db ls /docker-entrypoint-initdb.d.
- Start again and read the db logs for the init lines.
When it is red 4
npm ERR! The `npm ci` command can only install with an existing package-lock.json
Cause. package-lock.json was not created or is in .dockerignore.
Fix, step by step
- Create it with the npm install --package-lock-only step.
- Make sure .dockerignore does not list package-lock.json.
- Build again.
When it is red 5
redis error connect ECONNREFUSED 172.20.0.4:6379
Cause. REDIS_URL is set but Redis is not running or not healthy yet.
Fix, step by step
- Use the compose.cache.yaml file that also starts redis.
- Check docker compose ps shows redis healthy.
- The app keeps working without cache because errors are only logged.
Practice tasks
Easy Task 1: Build
Why: Start.
What to do: Build node-orders:1.0 and check the user.
- Use a VM or laptop with Docker and Compose.
- Create the node-orders folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker build -t node-orders:1.0 .
docker run --rm node-orders:1.0 whoamiEasy Task 2: MySQL stack
Why: Basics.
What to do: Start the MySQL stack and add two orders.
- Use a VM or laptop with Docker and Compose.
- Create the node-orders folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mysql.yaml -p orders-mysql up -d --wait
curl -s -X POST localhost:3000/orders -H 'Content-Type: application/json' -d '{"item":"Veg Biryani","qty":1}'Easy Task 3: Mongo stack
Why: Same image.
What to do: Run the Mongo stack without rebuilding.
- Use a VM or laptop with Docker and Compose.
- Create the node-orders folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mongo.yaml -p orders-mongo up -d --wait
curl -s localhost:3000/healthzMedium Task 4: Cache hit
Why: Speed.
What to do: Add Redis and show from:db then from:cache.
- Use a VM or laptop with Docker and Compose.
- Create the node-orders folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mysql.yaml -f compose.cache.yaml -p orders-mysql up -d --wait
curl -s localhost:3000/menu | cut -c1-20
curl -s localhost:3000/menu | cut -c1-20Medium Task 5: Data survives
Why: Volumes.
What to do: Remove the containers (not volumes) and prove the orders are still there.
- Use a VM or laptop with Docker and Compose.
- Create the node-orders folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mysql.yaml -p orders-mysql down
docker compose -f compose.mysql.yaml -p orders-mysql up -d --wait
curl -s localhost:3000/ordersMedium Task 6: Bad input
Why: Validation.
What to do: Send an order without qty and read the 400 error.
- Use a VM or laptop with Docker and Compose.
- Create the node-orders folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
curl -s -X POST localhost:3000/orders -H 'Content-Type: application/json' -d '{"item":"Dosa"}'Medium Task 7: Redis down
Why: Fault tolerance.
What to do: Stop Redis and prove the API still answers from the database.
- Use a VM or laptop with Docker and Compose.
- Create the node-orders folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -p orders-mysql stop redis
curl -s localhost:3000/menu | cut -c1-20Hard Task 8: Backup
Why: Safety.
What to do: Dump the orders database to a file on the host.
- Use a VM or laptop with Docker and Compose.
- Create the node-orders folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -p orders-mysql exec db mysqldump -uroot -pchange-me-root orders > orders-backup.sql
ls -lh orders-backup.sqlHard Task 9: Scale
Why: Many replicas.
What to do: Remove the fixed host port and run 3 API replicas.
- Use a VM or laptop with Docker and Compose.
- Create the node-orders folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
# change ports to - "3000" in compose.mysql.yaml
docker compose -f compose.mysql.yaml -p orders-mysql up -d --scale api=3
docker compose -p orders-mysql psEasy Task 10: Clean
Why: Habit.
What to do: Remove both stacks and the image.
- Use a VM or laptop with Docker and Compose.
- Create the node-orders folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mongo.yaml -p orders-mongo down -v
docker compose -f compose.mysql.yaml -f compose.cache.yaml -p orders-mysql down -v
docker image rm node-orders:1.0Homework: try at home
Add GET /orders/:id with a Redis cache of 60 seconds, delete the cache key when a new order is added, and show the from field change from db to cache in both the MySQL and MongoDB stacks.
Recap
- One Node image works with MySQL or MongoDB by environment variables.
- Init scripts seed the database only on the first start.
- Health checks plus depends_on stop the API starting too early.
- Redis cache-aside makes repeated reads much faster.
- A second -f file adds services without editing the first.
- down -v removes practice data; plain down keeps it.
FAQ
Q. Why node:20-alpine?
It is small (about 50 MB base) and mysql2, mongodb and redis are pure JavaScript, so no compiler is needed.
Q. Can I use Sequelize or Prisma?
Yes. Run their migrations as a one-off job, like Alembic in project 5.
Q. Should Redis have a volume?
For a pure cache, no. If you lose it, the app reads the database again.
Q. Why not put the password in the Dockerfile?
Anyone who pulls the image could read it with docker history. Use .env or secrets.
Q. Can both stacks run at the same time?
Yes, if you change one host port, for example 3001:3000 in compose.mongo.yaml.
Q. How do I see slow queries?
Use docker compose logs api and add timing logs, or turn on the MySQL slow query log.
Interview questions
-
How do you connect a container to a database container?
Model answer: Put both on the same Compose network and use the service name, like db:3306, as the host.
-
What is cache-aside and when does it go wrong?
Model answer: The app reads the cache, then the database on a miss. It goes wrong when data changes and the cache is not cleared, so set a TTL and delete keys on writes.
-
How do you seed a database in Docker?
Model answer: Mount .sql or .js files into /docker-entrypoint-initdb.d; they run once on an empty volume.
-
How do you keep one image for many environments?
Model answer: Read all settings from environment variables; never bake them into the image.
-
What happens to data when you run docker compose down?
Model answer: Containers and the network go away. Named volumes stay unless you add -v.
Quick quiz
Q1. What does DB_KIND do in this project?
It chooses which storage file Node loads, so one image works with MySQL or MongoDB.
Q2. When does an init script in docker-entrypoint-initdb.d run?
Only on the first start, when the data volume is empty.
Q3. What is cache-aside?
Read Redis first; on a miss read the database and save the answer in Redis with a TTL.
Q4. Why npm ci and not npm install in the Dockerfile?
npm ci installs exactly the lock file versions and is faster and repeatable.
Q5. How does compose.cache.yaml add Redis without changing the main file?
Compose merges the second -f file on top of the first.
Samajla ka?
Revision card
Interview card