57.2 Project 2: Todo app with Node.js and MySQL (Docker getting-started style)
In short: The app is Node.js with Express, and the data must live in MySQL.
Why: the problem
Suppose we join Infosys as freshers and the first task is a small internal todo app for our team. The app is Node.js with Express, and the data must live in MySQL. On a normal laptop we would install Node 20, install MySQL 8, create a user, create a database, and fix the root password three times. Our teammate on Windows does the same and gets different errors. With Docker Compose, the app and the database are two containers described in one file. Anyone runs docker compose up and gets the same Node version, the same MySQL version and the same data setup. This is the classic first project that Docker itself uses to teach beginners. We build it fully: REST API, a small web form, a MySQL volume so data survives restarts, a health check so the app waits for MySQL, and Adminer to look at the tables in the browser. At the end we test it, break it on purpose and clean everything.
What: every term in plain English
- Express — A small Node.js web framework. It maps URLs like GET /api/todos to functions.
- mysql2 — The Node.js library that talks to MySQL. It supports a connection pool.
- Service — One container definition in compose.yaml, like app, db or adminer.
- Named volume — Storage managed by Docker, like todo-mysql. Data stays after the container is removed.
- Service name DNS — Inside the Compose network the app reaches MySQL at the host name db.
- depends_on: service_healthy — Compose starts the app only after the db health check passes.
- Adminer — A one-file database admin web page. We run it as a third container.
- .env file — A file with settings like DB_PASSWORD. Compose reads it. It never goes to Git.
How: step by step
- Write the Node.js API. package.json and src/index.js with list, add, done and delete.
- Write the Dockerfile. node:20-alpine, npm ci, non-root user, health check.
- Write the database init SQL. Creates the todos table on first start.
- Write compose.yaml and .env. app, db and adminer with a volume and health checks.
- Start everything. docker compose up -d --build
- Test the API and the data. curl, Adminer and a restart test.
- Clean up. docker compose down -v
Architecture
Folder layout
todo-app/
src/
index.js
db/
init.sql
package.json
package-lock.json
Dockerfile
.dockerignore
compose.yaml
compose.override.yaml (dev only)
.env (never commit)
Example 1: Write the app code and the Dockerfile
Goal. Write the app code and the Dockerfile.
package.json
{
"name": "todo-app",
"version": "1.0.0",
"main": "src/index.js",
"scripts": { "start": "node src/index.js" },
"dependencies": {
"express": "^4.21.1",
"mysql2": "^3.11.3"
}
}
src/index.js
const express = require("express");
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
});
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.get("/healthz", async (req, res) => {
try { await pool.query("SELECT 1"); res.send("ok"); }
catch (e) { res.status(500).send("db down"); }
});
app.get("/api/todos", async (req, res) => {
const [rows] = await pool.query("SELECT id, title, done FROM todos ORDER BY id");
res.json(rows);
});
app.post("/api/todos", async (req, res) => {
const title = (req.body.title || "").trim();
if (!title) return res.status(400).json({ error: "title is required" });
const [r] = await pool.query("INSERT INTO todos (title) VALUES (?)", [title]);
res.status(201).json({ id: r.insertId, title, done: 0 });
});
app.patch("/api/todos/:id/done", async (req, res) => {
await pool.query("UPDATE todos SET done = 1 WHERE id = ?", [req.params.id]);
res.json({ id: Number(req.params.id), done: 1 });
});
app.delete("/api/todos/:id", async (req, res) => {
await pool.query("DELETE FROM todos WHERE id = ?", [req.params.id]);
res.status(204).end();
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log("todo app listening on " + port));
Dockerfile
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.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/index.js"]
.dockerignore
node_modules
.env
*.log
What each line does
mysql.createPool— Keeps up to 5 open connections and reuses them.host: db— db is the Compose service name. Docker DNS turns it into the MySQL container IP./healthz runs SELECT 1— The app is healthy only when MySQL answers.VALUES (?)— Placeholders stop SQL injection. Never join user text into SQL.COPY package*.json then npm ci— Dependencies are cached in their own layer, so code changes build fast.npm ci --omit=dev— Exact versions from package-lock.json, without dev tools.USER node— Runs as the non-root node user that the image already has.CMD exec form— node is PID 1 and gets the stop signal directly.
Green output
(no output yet: these are files. Run npm install once on any machine with Node, or inside a container, to create package-lock.json:)
docker run --rm -v "$PWD":/app -w /app node:20-alpine npm install --package-lock-only
added 0 packages, and audited 70 packages in 2s
Expected result. We have the app and a clean Dockerfile. package-lock.json exists, so npm ci works during the build.
Example 2: Add MySQL, Adminer and Compose
Goal. Add MySQL, Adminer and Compose.
db/init.sql
CREATE TABLE IF NOT EXISTS todos (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
done TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO todos (title) VALUES ('Install Docker'), ('Finish project 2');
.env
DB_NAME=todo
DB_USER=todo
DB_PASSWORD=change-me-app-pass
DB_ROOT_PASSWORD=change-me-root-pass
compose.yaml
services:
app:
build: .
image: todo-app:1.0
ports:
- "3000:3000"
environment:
DB_HOST: db
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- todo-mysql:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${DB_ROOT_PASSWORD}"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
adminer:
image: adminer:4
ports:
- "8081:8080"
depends_on:
- db
volumes:
todo-mysql:
What each line does
mysql:8.4— The current long-term support MySQL version. Pinned, not latest.MYSQL_DATABASE / USER / PASSWORD— The MySQL image creates this database and user on the first start.todo-mysql:/var/lib/mysql— MySQL data goes to a named volume and survives down and up.docker-entrypoint-initdb.d— SQL files here run once, only when the data folder is empty.mysqladmin ping— The health check. The db is healthy when MySQL accepts connections.condition: service_healthy— The app waits for a healthy db. No connection refused at start.${DB_PASSWORD}— Compose fills this from .env. The value is a placeholder: change it.8081:8080 adminer— Adminer listens on 8080 inside; we open it on 8081.
Green output
[+] Running 5/5
✔ Network todo-app_default Created
✔ Volume "todo-app_todo-mysql" Created
✔ Container todo-app-db-1 Healthy
✔ Container todo-app-app-1 Started
✔ Container todo-app-adminer-1 Started
Expected result. Three containers run on one private network. MySQL is healthy before the app starts.
Example 3: Test the API, the web form path and data safety
Goal. Test the API, the web form path and data safety.
Commands
curl -s localhost:3000/healthz
curl -s localhost:3000/api/todos
curl -s -X POST -H "Content-Type: application/json" -d '{"title":"Learn volumes"}' localhost:3000/api/todos
curl -s -X PATCH localhost:3000/api/todos/1/done
curl -s -X POST -H "Content-Type: application/json" -d '{"title":""}' localhost:3000/api/todos
docker compose exec db mysql -utodo -p"change-me-app-pass" todo -e "SELECT id,title,done FROM todos"
# data safety test
docker compose down
docker compose up -d
curl -s localhost:3000/api/todos
What each line does
/healthz— ok means the app can reach MySQL.POST with JSON— Adds a new todo. 201 means created.PATCH .../done— Marks todo 1 as done.empty title— The app answers 400. Input checks work.compose exec db mysql— Runs the mysql client inside the db container.down then up— Containers are removed and created again, but the volume keeps the rows.
Green output
ok
[{"id":1,"title":"Install Docker","done":0},{"id":2,"title":"Finish project 2","done":0}]
{"id":3,"title":"Learn volumes","done":0}
{"id":1,"done":1}
{"error":"title is required"}
id title done
1 Install Docker 1
2 Finish project 2 0
3 Learn volumes 0
...
[{"id":1,"title":"Install Docker","done":1},{"id":2,"title":"Finish project 2","done":0},{"id":3,"title":"Learn volumes","done":0}]
Expected result. All rows are still there after down and up. Open http://localhost:8081, choose MySQL, server db, user todo, and see the todos table in Adminer.
Example 4: Live code reload while developing (optional dev setup)
Goal. Live code reload while developing (optional dev setup).
compose.override.yaml
services:
app:
command: ["node", "--watch", "src/index.js"]
volumes:
- ./src:/app/src
environment:
NODE_ENV: development
Commands
docker compose up -d
docker compose logs -f app
# edit src/index.js, change the listen message, save the file
What each line does
compose.override.yaml— Compose merges this file automatically with compose.yaml.node --watch— Node 20 restarts the app when a file changes../src:/app/src— A bind mount: the container sees our edits at once.logs -f— Shows the restart happen live.
Green output
todo-app-app-1 | todo app listening on 3000
todo-app-app-1 | Restarting 'src/index.js'
todo-app-app-1 | todo app is ready on 3000
Expected result. We edit code on the laptop and the container restarts by itself. For the server we use only compose.yaml: docker compose -f compose.yaml up -d.
Every command, step by step (plain docker and Compose)
-
Create the folders
mkdir -p todo-app/src todo-app/db && cd todo-appWhat it does: Makes the project and its two sub folders. Then create the files from the examples.
(no output) -
Create package-lock.json
docker run --rm -v "$PWD":/app -w /app node:20-alpine npm install --package-lock-onlyWhat it does: Uses a throwaway Node container, so Node is not needed on the host.
added 0 packages, and audited 70 packages in 2s -
Check the Compose file
docker compose config --quiet && echo validWhat it does: Compose reads compose.yaml and .env and reports errors.
valid -
Build and start
docker compose up -d --buildWhat it does: Builds todo-app:1.0, creates the network and volume, starts db, waits for healthy, starts app and adminer.
Container todo-app-db-1 Healthy Container todo-app-app-1 Started -
See the status
docker compose ps --format 'table {{.Service}}\t{{.Status}}'What it does: All three are Up; app and db show healthy.
SERVICE STATUS adminer Up 30 seconds app Up 25 seconds (healthy) db Up 31 seconds (healthy) -
Read the logs
docker compose logs --tail 5 app dbWhat it does: Shows the app start line and MySQL ready line.
app-1 | todo app listening on 3000 db-1 | ready for connections. Version: '8.4.3' -
Test the API
curl -s localhost:3000/api/todosWhat it does: Lists the two rows from init.sql.
[{"id":1,"title":"Install Docker","done":0},...] -
Check the network
docker network inspect todo-app_default -f '{{range .Containers}}{{.Name}} {{end}}'What it does: All three containers share one network.
todo-app-db-1 todo-app-app-1 todo-app-adminer-1 -
Check the volume
docker volume inspect todo-app_todo-mysql -f '{{.Mountpoint}}'What it does: Where MySQL files live on a Linux host.
/var/lib/docker/volumes/todo-app_todo-mysql/_data -
Back up the database
docker compose exec -T db mysqldump -uroot -p"change-me-root-pass" todo > todo-backup.sql ls -lh todo-backup.sqlWhat it does: Writes a SQL dump on the host.
-rw-r--r-- 1 ubuntu ubuntu 2.1K Oct 25 10:40 todo-backup.sql -
Clean up, keep data
docker compose downWhat it does: Removes containers and the network. The volume stays.
Container todo-app-app-1 Removed Network todo-app_default Removed -
Clean up everything
docker compose down -v --rmi localWhat it does: Also removes the volume and the built image. Data is gone.
Volume todo-app_todo-mysql Removed Image todo-app:1.0 Removed
When it is red 1
Error: connect ECONNREFUSED 172.19.0.2:3306
Cause. The app started before MySQL was ready. This happens when depends_on has no condition.
Fix, step by step
- Add the db healthcheck.
- Use depends_on with condition: service_healthy.
- Run docker compose up -d again and check docker compose ps shows db healthy first.
When it is red 2
Error: getaddrinfo ENOTFOUND localhost (or ECONNREFUSED 127.0.0.1:3306)
Cause. DB_HOST is localhost. Inside a container, localhost means the container itself, not MySQL.
Fix, step by step
- Set DB_HOST: db, the service name.
- Run docker compose up -d to recreate the app.
- Test with docker compose exec app getent hosts db.
When it is red 3
Access denied for user 'todo'@'172.19.0.3' (using password: YES)
Cause. The password in .env was changed after the volume was created. The MySQL init only runs on an empty volume.
Fix, step by step
- Either use the old password again,
- or reset: docker compose down -v (deletes data), then docker compose up -d.
- For real data, change the password with ALTER USER inside MySQL instead.
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
- Run the npm install --package-lock-only step.
- Check that .dockerignore does not list package-lock.json.
- Build again with docker compose build app.
Practice tasks
Easy Task 1: Start it
Why: First run.
What to do: Bring the todo app up and list the todos.
- Use a VM or laptop with Docker and the Compose plugin (docker compose version).
- Create the todo-app folder with the files from this lesson.
- Run each step and compare your output with the solution.
Solution
docker compose up -d --build
curl -s localhost:3000/api/todosEasy Task 2: Add todos
Why: API practice.
What to do: Add three todos with curl.
- Use a VM or laptop with Docker and the Compose plugin (docker compose version).
- Create the todo-app folder with the files from this lesson.
- Run each step and compare your output with the solution.
Solution
for t in Milk Bread Tea; do curl -s -X POST -H 'Content-Type: application/json' -d "{\"title\":\"$t\"}" localhost:3000/api/todos; doneEasy Task 3: Adminer
Why: GUI.
What to do: Log in to Adminer and view the table.
- Use a VM or laptop with Docker and the Compose plugin (docker compose version).
- Create the todo-app folder with the files from this lesson.
- Run each step and compare your output with the solution.
Solution
Open http://localhost:8081
System MySQL, Server db, User todo, Password from .env, Database todoMedium Task 4: Data survives
Why: Volumes.
What to do: Run down and up and prove rows remain.
- Use a VM or laptop with Docker and the Compose plugin (docker compose version).
- Create the todo-app folder with the files from this lesson.
- Run each step and compare your output with the solution.
Solution
docker compose down
docker compose up -d
curl -s localhost:3000/api/todosMedium Task 5: Backup and restore
Why: Ops skill.
What to do: Dump the DB, wipe it, restore it.
- Use a VM or laptop with Docker and the Compose plugin (docker compose version).
- Create the todo-app folder with the files from this lesson.
- Run each step and compare your output with the solution.
Solution
docker compose exec -T db mysqldump -uroot -pchange-me-root-pass todo > b.sql
docker compose down -v && docker compose up -d
docker compose cp b.sql db:/tmp/b.sql
docker compose exec db sh -c "mysql -uroot -pchange-me-root-pass todo -e 'source /tmp/b.sql'"Medium Task 6: Break DNS
Why: Debugging.
What to do: Set DB_HOST to localhost and read the error.
- Use a VM or laptop with Docker and the Compose plugin (docker compose version).
- Create the todo-app folder with the files from this lesson.
- Run each step and compare your output with the solution.
Solution
docker compose up -d
docker compose logs --tail 5 appMedium Task 7: Dev reload
Why: Speed.
What to do: Use the override file and change the log text.
- Use a VM or laptop with Docker and the Compose plugin (docker compose version).
- Create the todo-app folder with the files from this lesson.
- Run each step and compare your output with the solution.
Solution
docker compose up -d
docker compose logs -f appHard Task 8: Two app copies
Why: Scaling.
What to do: Remove the fixed port and run two app replicas behind nothing yet.
- Use a VM or laptop with Docker and the Compose plugin (docker compose version).
- Create the todo-app folder with the files from this lesson.
- Run each step and compare your output with the solution.
Solution
docker compose up -d --scale app=2
docker compose ps appHard Task 9: Due dates
Why: Schema change.
What to do: Add a due_date column with ALTER TABLE.
- Use a VM or laptop with Docker and the Compose plugin (docker compose version).
- Create the todo-app folder with the files from this lesson.
- Run each step and compare your output with the solution.
Solution
docker compose exec db mysql -uroot -pchange-me-root-pass todo -e "ALTER TABLE todos ADD due_date DATE NULL"Easy Task 10: Clean
Why: Habit.
What to do: Remove everything including data.
- Use a VM or laptop with Docker and the Compose plugin (docker compose version).
- Create the todo-app folder with the files from this lesson.
- Run each step and compare your output with the solution.
Solution
docker compose down -v --rmi localHomework: try at home
Add a due_date column to the todos table with a second SQL file, an API field for it, and show overdue todos. Rebuild, test with curl, and back up the database to a file.
Recap
- Compose runs the app, the database and an admin tool together.
- The app finds MySQL by the service name db.
- A health check plus service_healthy stops start-up races.
- Named volumes keep data; down -v deletes it.
- init.sql runs only on an empty volume.
- Secrets come from .env with placeholder values.
FAQ
Q. Can I use Postgres instead of MySQL?
Yes. Change the image to postgres:16, the env names to POSTGRES_*, and the driver to pg. Project 7 does this.
Q. Why not put MySQL in the same container as Node?
One process per container is easier to update, scale and restart. Each part can fail and recover alone.
Q. Where is my data on Windows or Mac?
Inside the Docker Desktop Linux VM, in the named volume. Use docker volume inspect or mysqldump to get it.
Q. Is the .env file safe?
It is fine on your laptop or server. Add it to .gitignore and use real secret stores like AWS SSM in production.
Q. Why port 3000?
Express apps often use 3000. Only the published port matters to the outside.
Q. Can I use Docker Desktop?
Yes. Every command here is the same on Docker Desktop for Windows and Mac.
Interview questions
-
How do containers in one Compose project talk to each other?
Model answer: On the default project network, by service name through Docker's DNS.
-
How do you make sure the app waits for the database?
Model answer: A database healthcheck plus depends_on condition service_healthy, and retry logic in the app.
-
How do you persist MySQL data in Docker?
Model answer: Mount a named volume on /var/lib/mysql; back up with mysqldump.
-
Why use a connection pool?
Model answer: It reuses connections and limits how many the app opens to the database.
-
How do you keep dev and prod Compose different?
Model answer: compose.override.yaml for dev (bind mounts, watch) and run prod with -f compose.yaml only.
-
How do you avoid SQL injection?
Model answer: Use placeholders like ? with the driver, never string joins.
Quick quiz
Q1. Why does the app use host db and not localhost?
Each container has its own localhost. db is the service name, and Docker DNS gives its IP.
Q2. When does init.sql run?
Only on the first start, when the MySQL data volume is empty.
Q3. What does condition: service_healthy do?
It makes Compose wait until the db health check passes before starting the app.
Q4. What does docker compose down -v remove?
Containers, the network and the named volumes, so the data is deleted.
Q5. Why copy package.json before the source code?
So the npm ci layer is cached and rebuilds after code changes are fast.
Samajla ka?
Revision card
Interview card