57.3 Project 3: Database containers: MySQL, PostgreSQL, MongoDB and Redis with backups and admin UIs
Why: the problem
Suppose we are on the platform team at Swiggy. Every developer needs a MySQL for the orders service, a PostgreSQL for payments, a MongoDB for restaurant menus and a Redis for the cart. Installing four database servers on every laptop takes a full day and breaks the laptop. Different versions on different laptops cause bugs that only one person can see. With Docker, each database is one container with a pinned version, its data in a named volume, and its first-time setup in an init script. We can start all four in one command, back them up to files, restore them, and throw them away when we are done. We also add web admin tools: Adminer for MySQL and Postgres, mongo-express for MongoDB and RedisInsight for Redis. In this project we learn the rules that matter for every database container: never lose data, never use latest, never put real passwords in files, and always test a restore.
What: every term in plain English
- Database container — A database server process running from an official image, like mysql:8.4 or postgres:16.
- Data directory — The folder where the database keeps its files. We mount a named volume there.
- Init scripts — Files in /docker-entrypoint-initdb.d. They run once, on an empty data directory.
- Logical backup — A dump of data as SQL or BSON: mysqldump, pg_dump, mongodump.
- Restore test — Loading a backup into a fresh database to prove the backup works.
- Healthcheck — A command that tells Docker if the database accepts connections.
- Bind to 127.0.0.1 — Publishing a port as 127.0.0.1:3306:3306 so only the host can reach it, not the internet.
- Admin UI — A web page to browse tables and documents: Adminer, mongo-express, RedisInsight.
How: step by step
- Write .env with placeholder passwords. One file for all four databases.
- Write init scripts. SQL for MySQL and Postgres, JS for MongoDB.
- Write compose.yaml. Four databases, four volumes, health checks, local-only ports.
- Add admin UIs. Adminer, mongo-express and RedisInsight on their own ports.
- Start and check health. docker compose up -d and docker compose ps.
- Back up and restore. Dump each database to a file, wipe, load back.
- Clean up. docker compose down -v when you are done.
Architecture
Folder layout
dbs/
.env
compose.yaml
compose.admin.yaml
init/
mysql/01-orders.sql
postgres/01-payments.sql
mongo/01-menus.js
backups/ (created by the backup steps)
Example 1: Four databases with volumes and health checks
Goal. Four databases with volumes and health checks.
.env
MYSQL_ROOT_PASSWORD=change-me-mysql-root
MYSQL_PASSWORD=change-me-orders
POSTGRES_PASSWORD=change-me-payments
MONGO_ROOT_PASSWORD=change-me-mongo
REDIS_PASSWORD=change-me-redis
init/mysql/01-orders.sql
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
restaurant VARCHAR(100) NOT NULL,
amount DECIMAL(8,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO orders (restaurant, amount) VALUES ('Vaishali', 240.00), ('Goodluck Cafe', 180.50);
init/postgres/01-payments.sql
CREATE TABLE payments (
id SERIAL PRIMARY KEY,
order_id INT NOT NULL,
method TEXT NOT NULL,
paid_at TIMESTAMPTZ DEFAULT now()
);
INSERT INTO payments (order_id, method) VALUES (1, 'UPI'), (2, 'card');
init/mongo/01-menus.js
db = db.getSiblingDB("menus");
db.restaurants.insertMany([
{ name: "Vaishali", city: "Pune", items: ["Dosa", "SPDP", "Cold coffee"] },
{ name: "Goodluck Cafe", city: "Pune", items: ["Bun maska", "Chai"] }
]);
compose.yaml
services:
mysql:
image: mysql:8.4
environment:
MYSQL_DATABASE: orders
MYSQL_USER: orders
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
ports: ["127.0.0.1:3306:3306"]
volumes:
- mysql-data:/var/lib/mysql
- ./init/mysql:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s
retries: 20
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: payments
POSTGRES_USER: payments
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports: ["127.0.0.1:5432:5432"]
volumes:
- pg-data:/var/lib/postgresql/data
- ./init/postgres:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U payments -d payments"]
interval: 5s
retries: 20
mongo:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
ports: ["127.0.0.1:27017:27017"]
volumes:
- mongo-data:/data/db
- ./init/mongo:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
retries: 10
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
ports: ["127.0.0.1:6379:6379"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a $$REDIS_PASSWORD ping | grep PONG"]
interval: 5s
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes:
mysql-data:
pg-data:
mongo-data:
redis-data:
What each line does
127.0.0.1:3306:3306— Only programs on this machine can connect. The database is not open to the internet.mysql-data:/var/lib/mysql— MySQL data folder on a named volume.pg-data:/var/lib/postgresql/data— Postgres data folder on a named volume.mongo-data:/data/db— MongoDB data folder on a named volume.--appendonly yes— Redis writes every change to disk, so the cart survives a restart.--requirepass— Redis asks for a password. Without it anyone on the network can read the cart.pg_isready— The official Postgres readiness check.$$REDIS_PASSWORD— Two dollar signs: Compose leaves one $ for the shell inside the container.
Green output
NAME IMAGE STATUS
dbs-mongo-1 mongo:7 Up 40 seconds (healthy)
dbs-mysql-1 mysql:8.4 Up 40 seconds (healthy)
dbs-postgres-1 postgres:16-alpine Up 40 seconds (healthy)
dbs-redis-1 redis:7-alpine Up 40 seconds (healthy)
Expected result. Four databases run with pinned versions, their own volumes, and health checks. None is open to the internet.
Example 2: Connect to each database and run a query
Goal. Connect to each database and run a query.
Commands
docker compose exec mysql mysql -uorders -p"change-me-orders" orders -e "SELECT * FROM orders"
docker compose exec postgres psql -U payments -d payments -c "SELECT * FROM payments"
docker compose exec mongo mongosh -u root -p "change-me-mongo" --quiet --eval 'db.getSiblingDB("menus").restaurants.find({}, {_id:0,name:1}).toArray()'
docker compose exec redis redis-cli -a "change-me-redis" SET cart:42 '{"items":2}'
docker compose exec redis redis-cli -a "change-me-redis" GET cart:42
What each line does
compose exec mysql mysql— Runs the mysql client that is already inside the MySQL image.psql -U payments— The Postgres client, logged in as the payments user.mongosh --eval— Runs one MongoDB command and exits.redis-cli SET / GET— Stores and reads a cart value.-a— The Redis password. Redis prints a warning when it is on the command line; fine for practice.
Green output
id restaurant amount created_at
1 Vaishali 240.00 2026-10-25 10:50:01
2 Goodluck Cafe 180.50 2026-10-25 10:50:01
id | order_id | method | paid_at
1 | 1 | UPI | 2026-10-25 10:50:02+00
2 | 2 | card | 2026-10-25 10:50:02+00
[ { name: 'Vaishali' }, { name: 'Goodluck Cafe' } ]
OK
"{\"items\":2}"
Expected result. The init scripts filled every database. We can query them without installing any client on the host.
Example 3: Back up all four, wipe them, and restore
Goal. Back up all four, wipe them, and restore.
Commands
mkdir -p backups
docker compose exec -T mysql mysqldump -uroot -p"change-me-mysql-root" --single-transaction orders > backups/orders.sql
docker compose exec -T postgres pg_dump -U payments -d payments -Fc > backups/payments.dump
docker compose exec -T mongo mongodump -u root -p "change-me-mongo" --authenticationDatabase admin --db menus --archive --gzip > backups/menus.archive.gz
docker compose exec redis redis-cli -a "change-me-redis" SAVE
docker compose cp redis:/data/dump.rdb backups/redis-dump.rdb
ls -lh backups
# wipe everything, including volumes
docker compose down -v
docker compose up -d --wait
# restore
docker compose cp backups/orders.sql mysql:/tmp/orders.sql
docker compose exec mysql sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" orders -e "source /tmp/orders.sql"'
docker compose cp backups/payments.dump postgres:/tmp/payments.dump
docker compose exec postgres pg_restore -U payments -d payments --clean --if-exists /tmp/payments.dump
docker compose cp backups/menus.archive.gz mongo:/tmp/menus.archive.gz
docker compose exec mongo mongorestore -u root -p "change-me-mongo" --authenticationDatabase admin --archive=/tmp/menus.archive.gz --gzip --drop
What each line does
--single-transaction— A consistent MySQL dump without locking tables.pg_dump -Fc— Postgres custom format. pg_restore can load it.mongodump --archive --gzip— One compressed file for the whole database.redis-cli SAVE then cp— Writes dump.rdb and copies it to the host.up -d --wait— Waits until every service is healthy before returning.compose cp then source— Copies the dump in and loads it, without shell input redirection.pg_restore --clean --if-exists— Drops tables that the init script made, then loads the backup.mongorestore --drop— Replaces the collections with the backup.
Green output
-rw-r--r-- 1 ubuntu ubuntu 2.3K orders.sql
-rw-r--r-- 1 ubuntu ubuntu 1.9K payments.dump
-rw-r--r-- 1 ubuntu ubuntu 412 menus.archive.gz
-rw-r--r-- 1 ubuntu ubuntu 118 redis-dump.rdb
...
2 document(s) restored successfully. 0 document(s) failed to restore.
Expected result. A backup counts only if a restore works. We proved it for every database.
Example 4: Add web admin tools for all databases
Goal. Add web admin tools for all databases.
compose.admin.yaml
services:
adminer:
image: adminer:4
ports: ["127.0.0.1:8081:8080"]
mongo-express:
image: mongo-express:1
environment:
ME_CONFIG_MONGODB_URL: mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/
ME_CONFIG_BASICAUTH_USERNAME: admin
ME_CONFIG_BASICAUTH_PASSWORD: change-me-ui
ports: ["127.0.0.1:8082:8081"]
depends_on:
mongo:
condition: service_healthy
redisinsight:
image: redis/redisinsight:latest
ports: ["127.0.0.1:5540:5540"]
Commands
docker compose -f compose.yaml -f compose.admin.yaml up -d
docker compose -f compose.yaml -f compose.admin.yaml ps --format '{{.Service}} {{.Ports}}'
What each line does
-f compose.yaml -f compose.admin.yaml— Merges two files. Admin tools run only when we ask.adminer on 8081— Use server mysql or postgres as the host name.mongo-express— Browse MongoDB at localhost:8082 with a basic login.redisinsight— Add a database with host redis, port 6379 and the Redis password.
Green output
adminer 127.0.0.1:8081->8080/tcp
mongo-express 127.0.0.1:8082->8081/tcp
redisinsight 127.0.0.1:5540->5540/tcp
mysql 127.0.0.1:3306->3306/tcp
...
Expected result. Open localhost:8081 for MySQL and Postgres, 8082 for MongoDB and 5540 for Redis. On a remote server, reach them with an SSH tunnel: ssh -L 8081:localhost:8081 ubuntu@SERVER.
Every command, step by step (plain docker and Compose)
-
Make folders
mkdir -p dbs/init/mysql dbs/init/postgres dbs/init/mongo && cd dbsWhat it does: Creates the project and the init folders.
(no output) -
Validate Compose
docker compose config --servicesWhat it does: Lists the four services if the file is valid.
mysql postgres mongo redis -
Start and wait
docker compose up -d --waitWhat it does: Starts all four and returns when all are healthy.
Container dbs-mysql-1 Healthy Container dbs-postgres-1 Healthy Container dbs-mongo-1 Healthy Container dbs-redis-1 Healthy -
Check volumes
docker volume ls --filter name=dbs_What it does: Four named volumes, one per database.
local dbs_mongo-data local dbs_mysql-data local dbs_pg-data local dbs_redis-data -
Check memory use
docker stats --no-stream --format '{{.Name}} {{.MemUsage}}'What it does: See how much RAM each database takes.
dbs-mysql-1 410MiB / 3.8GiB dbs-mongo-1 160MiB / 3.8GiB dbs-postgres-1 38MiB / 3.8GiB dbs-redis-1 4MiB / 3.8GiB -
Check the port is local only
sudo ss -ltnp | grep -E '3306|5432|27017|6379'What it does: Every port shows 127.0.0.1, not 0.0.0.0.
LISTEN 0 4096 127.0.0.1:3306 ... LISTEN 0 4096 127.0.0.1:5432 ... -
See the init log
docker compose logs mysql | grep -i initdbWhat it does: Shows that our SQL file ran on first start.
running /docker-entrypoint-initdb.d/01-orders.sql -
Prove init runs only once
docker compose restart mysql docker compose logs --since 1m mysql | grep -c initdb.dWhat it does: After restart the volume is not empty, so init does not run again.
0 -
Limit memory for MySQL (optional)
docker update --memory 512m --memory-swap 512m dbs-mysql-1What it does: Caps RAM so one database cannot eat the laptop.
dbs-mysql-1 -
Clean up, keep data
docker compose downWhat it does: Containers go, volumes stay.
Container dbs-redis-1 Removed ... -
Clean up everything
docker compose -f compose.yaml -f compose.admin.yaml down -vWhat it does: Removes the admin tools, the databases and all data.
Volume dbs_mysql-data Removed Volume dbs_pg-data Removed Volume dbs_mongo-data Removed Volume dbs_redis-data Removed
When it is red 1
initdb: error: directory "/var/lib/postgresql/data" exists but is not empty
Cause. We used a bind mount to a folder that has other files, like lost+found on a new disk.
Fix, step by step
- Use a named volume instead of a bind mount.
- Or set PGDATA=/var/lib/postgresql/data/pgdata so Postgres uses a sub folder.
When it is red 2
MongoServerError: Authentication failed.
Cause. The root password in .env changed after the first start, or authSource is missing.
Fix, step by step
- Use --authenticationDatabase admin for the root user.
- Use the old password, or reset with docker compose down -v (deletes data).
When it is red 3
[ERROR] [MY-012574] [InnoDB] Unable to lock ./ibdata1 error: 11
Cause. Two MySQL containers use the same volume at the same time.
Fix, step by step
- Run docker ps and stop the extra MySQL container.
- Never share one database volume between two running containers.
When it is red 4
dbs-mysql-1 exited with code 137
Cause. The host ran out of memory and the kernel killed MySQL (OOM).
Fix, step by step
- Check docker inspect -f '{{.State.OOMKilled}}' dbs-mysql-1.
- Stop databases you do not need, add swap, or use a bigger VM.
- Set a lower innodb_buffer_pool_size with command: --innodb-buffer-pool-size=128M.
Practice tasks
Easy Task 1: Start all
Why: First run.
What to do: Start all four databases and wait for healthy.
- Use a VM or laptop with Docker and Compose.
- Make a folder called dbs with the files from this lesson.
- Run the steps one by one and compare with the solution.
Solution
docker compose up -d --wait
docker compose psEasy Task 2: Query MySQL
Why: Practice.
What to do: Count the orders.
- Use a VM or laptop with Docker and Compose.
- Make a folder called dbs with the files from this lesson.
- Run the steps one by one and compare with the solution.
Solution
docker compose exec mysql mysql -uorders -pchange-me-orders orders -e 'SELECT COUNT(*) FROM orders'Easy Task 3: Query Mongo
Why: Practice.
What to do: Find the Pune restaurants.
- Use a VM or laptop with Docker and Compose.
- Make a folder called dbs with the files from this lesson.
- Run the steps one by one and compare with the solution.
Solution
docker compose exec mongo mongosh -u root -p change-me-mongo --quiet --eval 'db.getSiblingDB("menus").restaurants.countDocuments({city:"Pune"})'Medium Task 4: Backup MySQL
Why: Safety.
What to do: Dump orders to a dated file.
- Use a VM or laptop with Docker and Compose.
- Make a folder called dbs with the files from this lesson.
- Run the steps one by one and compare with the solution.
Solution
docker compose exec -T mysql mysqldump -uroot -pchange-me-mysql-root --single-transaction orders > orders-$(date +%F).sqlMedium Task 5: Restore test
Why: Proof.
What to do: Wipe and restore Postgres.
- Use a VM or laptop with Docker and Compose.
- Make a folder called dbs with the files from this lesson.
- Run the steps one by one and compare with the solution.
Solution
docker compose exec postgres pg_restore -U payments -d payments --clean --if-exists /tmp/payments.dumpMedium Task 6: Admin UIs
Why: GUI.
What to do: Start the admin tools and log in to each.
- Use a VM or laptop with Docker and Compose.
- Make a folder called dbs with the files from this lesson.
- Run the steps one by one and compare with the solution.
Solution
docker compose -f compose.yaml -f compose.admin.yaml up -dMedium Task 7: Version upgrade
Why: Ops.
What to do: Move Redis from 7.2 to 7.4 by changing the tag.
- Use a VM or laptop with Docker and Compose.
- Make a folder called dbs with the files from this lesson.
- Run the steps one by one and compare with the solution.
Solution
docker compose pull redis
docker compose up -d redis
docker compose exec redis redis-server --versionHard Task 8: Tunnel
Why: Remote access.
What to do: Reach Adminer on a remote VM with an SSH tunnel.
- Use a VM or laptop with Docker and Compose.
- Make a folder called dbs with the files from this lesson.
- Run the steps one by one and compare with the solution.
Solution
ssh -L 8081:localhost:8081 ubuntu@SERVER_IP
open http://localhost:8081Hard Task 9: Memory cap
Why: Stability.
What to do: Limit MySQL to 512 MB in compose.yaml with deploy.resources.limits.memory.
- Use a VM or laptop with Docker and Compose.
- Make a folder called dbs with the files from this lesson.
- Run the steps one by one and compare with the solution.
Solution
deploy:
resources:
limits:
memory: 512mEasy Task 10: Clean
Why: Habit.
What to do: Remove everything.
- Use a VM or laptop with Docker and Compose.
- Make a folder called dbs with the files from this lesson.
- Run the steps one by one and compare with the solution.
Solution
docker compose -f compose.yaml -f compose.admin.yaml down -vHomework: try at home
Write a small backup.sh script that dumps all four databases with today's date in the file name, keeps only the last 7 files, and run it daily with cron. Test one restore every week.
Recap
- Each database is one container with a pinned version.
- Data lives in a named volume on the data directory.
- Init scripts run once on an empty volume.
- Publish ports on 127.0.0.1 only.
- Back up with mysqldump, pg_dump, mongodump and Redis SAVE.
- A backup is real only after a restore test.
FAQ
Q. Should I run production databases in Docker?
Many teams do for small apps, with volumes and backups. Big companies like Swiggy usually use managed services like Amazon RDS for production. Chapter 58 shows RDS.
Q. Is latest okay for databases?
No. A major version jump can make the data folder unreadable. Pin mysql:8.4, postgres:16, mongo:7.
Q. How do I upgrade Postgres 15 to 16?
Dump with pg_dump from 15, start a new 16 container with a new volume, restore. Do not point 16 at the old 15 data folder.
Q. Where are volumes on disk?
On Linux in /var/lib/docker/volumes. On Docker Desktop they are inside its VM.
Q. Can two apps share one database container?
Yes, with different databases and users inside it. They must not share the data volume with another database container.
Q. Why does mongo-express need depends_on?
It connects at start and exits if MongoDB is not ready.
Interview questions
-
How do you persist database data in containers?
Model answer: Named volume on the data directory; never rely on the container layer.
-
How do you back up a MySQL container?
Model answer: docker exec mysqldump --single-transaction to a host file, and test restore regularly.
-
How do you secure a database container?
Model answer: No public port (bind to 127.0.0.1 or no ports at all), strong secrets from env or secret files, pinned versions, and a private network.
-
What are docker-entrypoint-initdb.d scripts?
Model answer: Scripts the official images run once on first initialisation to create schema and seed data.
-
How do you upgrade a database major version in Docker?
Model answer: Logical dump from the old version, restore into a new container and volume, then switch the app.
Quick quiz
Q1. Why publish database ports as 127.0.0.1:PORT:PORT?
So only the host can connect; the database is not exposed to the internet.
Q2. When do init scripts run?
Only on the first start, when the data directory is empty.
Q3. Which command backs up Postgres in custom format?
pg_dump -Fc, restored with pg_restore.
Q4. Why does Redis need --appendonly yes here?
So data is written to disk and survives restarts.
Q5. What does exit code 137 usually mean for a database?
It was killed, often because the host ran out of memory.
Samajla ka?
Revision card
Interview card