Series Navigation:
- ✅ Part 1: Setting Up Locally (Required)
- ✅ Part 2: Frontend Multi-Stage Docker Builds (Optional for this guide)
- 📍 Part 3: Containerizing the Backend & MySQL (You are here)
This is Part 3 of our series. If you haven't set up the local Full-Stack Todo app yet, check out Part 1 Setting Up Locally to get your environment ready.
If you do not containerize the frontend app, check out Part 2 Frontend Multi-Stage Docker Builds.
If you want to skip both, follow TL;DR: Fast-Track Setup section.
TL;DR: Fast-Track Setup
⚠️ Prerequisite: Before starting this step, you must have Docker Desktop and Node.js installed and running on your machine.
If you didn't follow along with the previous posts or simply want to skip the manual local setup, you can easily catch up. Grab the existing code from the repository and spin it up using Docker to jump right into the rest of the content.
1. Clone the repository and checkout the part-2-frontend branch:
Check out the repo link
git clone https://github.com/utkarsh4321/devops-todo-starter-app.git
cd devops-todo-starter-app
git checkout part-2-frontend
cd frontend
2. Build the Docker image:
docker build -t my-frontend-secure-todo-multistage:v2 .
3. Run the container:
docker run -it --env-file ./.env -p 8080:8080 --name todo-app-multistage-secure-container my-frontend-secure-todo-multistage:v2
Once your container is up and running on port 8080, validate frontend in the browser.
4. Set up MySQL container:
In the terminal run
docker run --name mysql-container \
-e MYSQL_ROOT_PASSWORD=myrootpassword \
-e MYSQL_DATABASE=my_testing_db \
-p 3306:3306 \
-d mysql:8.0
5. Set up backend environment:
Inside the backend folder, create a .env file. Copy the environment variables from below and paste them
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=my_testing_db
DB_USERNAME=root
DB_PASSWORD=myrootpassword
ALLOWED_ORIGIN=http://localhost:8080
6. Run the backend:
Open a new terminal and run
cd backend
npm i
npm run migrate
npm run dev
Once your backend is up and running on port 5500, you are completely caught up! Let's continue with the rest of the blog post.
Intro
In Part 3, we will guide you on how to containerize our Backend application with a MySQL database.
If you are a backend developer and only want to know how to Dockerize backend, this section is very important.
Here is what we will cover:
- Backend Code Walkthrough: A brief overview of our Backend app structure.
- Generating the Production Build: How to prepare the app for deployment.
- Deployment Architecture: High-level view of our deployment
- Writing multistage dockerfile for our backend: Writing the step-by-step build instructions.
- Building Docker Image: Building Docker Image and Running it.
- Persisting MySQL Data in Docker Containers: Secure your MySQL data against container destruction.
- Isolating Services with Docker Networks: Use custom internal networks to securely connect your backend to MySQL.
- Automating with Docker Compose: Spin up your complete architecture, including networks and volumes, with just one command.
- Bonus Section: Add a React frontend to complete your stack.
- Wrap Up
1. Backend Code Walkthrough: A brief overview of our Backend app structure.
Prefer Video? Watch Here
Our Backend Tech stack is:
- Express js
- Drizzle ORM
- Esbuild as a bundler
- Mysql DB
Our backend file structure looks like
backend/
├── drizzle/
├── node_modules/
├── routes/
├── .env
├── .env-example
├── .gitignore
├── db.js
├── drizzle.config.js
├── esbuild.config.js
├── index.js
├── migrate.js
├── package-lock.json
├── package.json
└── schema.js
Explanation of each files and folders
- drizzle/: Stores database migration files and snapshots generated by Drizzle ORM to track schema changes.
- node_modules/: Contains all the installed third-party libraries and dependencies required for the project to run.
- routes/: Define routes for the todos endpoint.
- db.js: likely the setup file that initializes and exports the connection to our database.
- drizzle.config.js: The configuration file for Drizzle Kit, telling it where to find your schema and where to output migrations.
- index.js: The entry point for our backend.
- migrate.js: A custom script to run migrations. With some dummy-seeded todos.
- package-lock.json: Locks down the exact versions of your dependencies.
- package.json: The project manifest.
- schema.js: Defines your database tables and columns using Drizzle syntax.
- esbuild.config.js: Configuration for esbuild. Using this to bundle your backend code for deployment or to transpile TypeScript/modern JS.
2. Generating the Production Build: How to prepare the app for deployment.
Prefer Video? Watch Here
Our backend project required the following steps for deployment
- Database migration
- Build creation
- Securely providing env variables
- A health check that ensures that the DB and backend are running successfully.
The 3rd and 4th points we see later. For 1st and 2nd points, we need to check our package.json file.
Our package.json looks like
In the script section, we need to focus on these commands:
generate: Script used to generate DB migrations.
migrate: Script used to run migration on the database.
build: Script used to create a bundle.
Note: Whenever the backend container starts, we need to run our migration on the DB first. Then our backend will run. Otherwise, our backend gives an error.
We already ran the npm run migrate command. Now, let's see how to build our backend application.
Go to the backend directory and then run
npm run build
Once you run the above command. It will create a dist directory inside the backend folder. This is our backend bundled code that we need for production.
3. Deployment Architecture:High-level view of our deployment
Prefer Video? Watch Here
Let’s Understand High-Level View
At the end of the blog post, we will achieve the architecture described above.
Here we need two containers, one for our MySQL DB and another for our Backend service.
Generally in prod. We expose our backend api’s but not the DB. DB is present inside the secure network that can only be accessed via our backend service.
4. Writing multistage dockerfile for our backend: Writing the step-by-step build instructions.
Prefer Video? Watch Here
We are directly creating a multistage Docker file for our backend.
Create a Dockerfile inside the backend folder.
Then, copy and paste the code snippet into the Dockerfile
FROM node:lts-alpine3.22 AS base
WORKDIR /app
# 2. Deps Stage: Install ONLY production dependencies
FROM base AS deps
# Use cache mount to install fast and keep image clean
RUN --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=cache,target=/root/.npm \
npm ci --omit=dev
# 3. Builder Stage: Install ALL deps and build
FROM base AS builder
RUN --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=cache,target=/root/.npm \
npm ci
# COPY source code AFTER installing deps to preserve cache
COPY . .
RUN npm run build
# 4. Final Stage: The Production Runner
FROM base AS runner
WORKDIR /backend-app
ENV NODE_ENV=production
RUN addgroup -S backend_group && adduser -S backend_user -G backend_group
# Copy the entrypoint script
COPY docker-entrypoint.sh /backend-app/docker-entrypoint.sh
RUN sed -i 's/\r$//' /backend-app/docker-entrypoint.sh
# Copy necessary files from previous stages
COPY --from=deps /app/node_modules /backend-app/node_modules
COPY --from=builder /app/dist /backend-app
RUN chown -R root:backend_group /backend-app
RUN chmod -R 750 /backend-app
# Run as non-root user for security
USER backend_user
EXPOSE 5500
ENTRYPOINT ["/backend-app/docker-entrypoint.sh"]
Step-by-step explanation of Docker file
-
Base Stage
Establishes the common foundation for later stages.-
FROM node:lts-alpine3.22 AS base: Starts using a lightweight Alpine Linux image with Node.js installed. Name this stage "base". -
WORKDIR /app: Sets /app as the current working directory inside the image for future commands.
-
-
Deps Stage
Focuses solely on installing production dependencies accurately and quickly.-
FROM base AS deps: Starts a new temporary stage named "deps", inheriting from the "base" stage. -
RUN --mount=... npm ci --omit=dev: Installs only production dependencies defined inpackage-lock.json. It uses specialized Docker "mounts" to cache downloaded packages and temporarily read dependency files without copying them permanently inside the image.
-
-
Builder Stage
Used to compile the source code (e.g., TypeScript to JavaScript).-
FROM base AS builder: Starts another temporary stage named "builder" from the "base" stage. -
RUN --mount=... npm ci: Installs all dependencies, including development tools needed for building (like TypeScript), using caching for speed. -
COPY . .: Copies the actual application source code from your host machine into the container's/appfolder. -
RUN npm run build: Executes the build script defined in yourpackage.jsonto compile the code (usually outputting to adistfolder).
-
-
Final Stage (Runner)
Assembles the final, clean production image.-
FROM base AS runner: Starts the final stage from the fresh "base" image, leaving behind build tools from previous stages. -
WORKDIR /backend-app: Sets the default working directory inside the container where all subsequent commands will be executed and files will be placed. -
ENV NODE_ENV=production: Sets an environment variable to tell Node.js to run in optimized production mode. -
RUN addgroup -S backend_group && adduser -S backend_user -G backend_group: Creates a dedicated system group and user to run the application securely, avoiding the risks of using the default root user. -
COPY docker-entrypoint.sh /backend-app/docker-entrypoint.sh: Copies a custom startup script from your project into the final image. -
RUN sed -i 's/\r$//' /backend-app/docker-entrypoint.sh: A handy quick-fix! It removes any hidden Windows line endings from your startup script so it runs flawlessly in our Linux environment. -
COPY --from=deps /app/node_modules /backend-app/node_modules: Copies the clean production-only dependencies from the "deps" stage. -
COPY --from=builder /app/dist /backend-app: Copies the compiled application files (thedistfolder) from the "builder" stage. -
RUN chown -R root:backend_group /backend-app: Safely assigns ownership of your app files. The root user stays in charge, but our newbackend groupgets the access it needs. -
RUN chmod -R 750 /backend-app: Applies strict security permissions, giving the owner full control, the group read/execute rights, and blocks everyone else. -
USER backend_user: Switches from the default root user to the low-privilege "backend_user" user for enhanced security. -
EXPOSE 5500: Documents that the application inside the container listens on port 5500. -
ENTRYPOINT ["/backend-app/docker-entrypoint.sh"]: Specifies the command that runs immediately when the container starts.
-
We created our Docker file
Also, create docker-entrypoint.sh in the backend folder. This script file will apply migrations on the database and then run the backend server.
Copy and paste the given code snippet into the docker-entrypoint.sh
#!/bin/sh
set -e
# Run database migrations
echo "Running database migrations..."
npm run migrate
echo "Migrations completed."
# Start the Node.js application
echo "Starting Node.js application..."
node ./bundle.js
The docker-entrypoint.sh Script
To ensure our container starts correctly and handles database migrations, we use a startup script. Here is the breakdown:
#!/bin/sh: Interpreter Declaration
Tells the system to run this file using the standard, lightweight Shell (sh) interpreter.set -e: Fail-Fast Mode
Configures the script to exit immediately if any command returns an error. This is a best practice that prevents the app from starting in a broken state if migrations fail.npm run migrate: Execute Migrations
Runs the migration script defined in yourpackage.jsonto update your database schema to the latest version before the app starts.node ./bundle.js: Start Server
Executes the Node.js runtime on your compiled application file. This is the main process that keeps the container alive and serving requests.
Also, create a .dockerignore file as well inside the backend directory.
And add the following code snippet
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/build
**/dist
LICENSE
README.md
/*.yaml
/*.yml
5. Building Docker Image: Building a Docker Image and Running it.
Prefer Video? Watch Here
We're all set up, let’s build an image and then run our backend container. Make sure your MySQL container is already running.
Please start the MySQL container from Docker Desktop. If you have never created a MySQL container before. Please follow this section Setting up the Database from the Part-1
Once you set up the MySQL container. Go to the backend directory in the terminal and run the following command
docker build -t my-todo-backend-image:v1 .
Hope your Docker image got created. You can check inside the Docker Desktop, in the image section.
Now let’s run a new container for our todo backend in the same terminal, run the following command
Note: Before running any command, please stop your already running dev server for the backend if you previously started it. Otherwise, the port will be occupied. And we are not able to run our container.
docker run -it -p 5500:5500 --env-file ./.env --name my-todo-backend-container my-todo-backend-image:v1
Understanding the Command
When we spin up our backend, we use a single, powerful command. Here is exactly what is happening under the hood:
docker run -it -p 5500:5500 --env-file ./.env --name my-todo-backend-container my-todo-backend-image:v1
-
docker run: The core command that tells Docker to create and start a new container. -
-it: A combination of-i(interactive) and-t(tty). This connects your terminal to the container, which is perfect for watching live logs or debugging. -
-p 5500:5500: Port mapping (host_port:container_port). This binds port5500on your local machine to port5500inside the isolated container so you can access your API vialocalhost:5500. -
--env-file ./.env: Security best practice! Instead of hardcoding secrets, this loads our environment variables (like database URIs or API keys) directly from our local.envfile. -
--name my-todo-backend-container: Assigns a readable, custom name to the container. If you skip this, Docker assigns a random, quirky name (likesleepy_einstein), making it harder to find later. -
my-todo-backend-image:v1: The blueprint. This tells Docker exactly which image and version (v1tag) to use to build the container.
Pro-tip: If you want this container to run quietly in the background without tying up your terminal, swap the
-itflag for-d(detached mode)!
Once you run it, your container will crash. Oops, what happened this
If you follow the above instructions, you will be able to see the above error. After running the above command.
So what we did wrong. Why are we getting this error?🤔
If you see the error. We are getting this error from line no 10 inside the migrate.js file.
Let’s check our migrate.js file
In line no 10 we are calling mysql.createConnection. Ok, the issue is with the database connection.
Let’s check our .env file
The localhost Problem
-
The Setup: In our environment variables, we set
DB_HOSTto localhost. - Why it worked before: This setup works perfectly when running the backend directly on our local machine.
- The Container Shift: Right now, however, we are running the backend inside an isolated Docker container.
-
The Core Issue: Inside this specific backend container, there is no MySQL server running on its internal
localhost. - The Error: When the code runs, it searches the container's internal network for a database, comes up empty-handed, and throws an error.
- The Reality: Our MySQL server is actually running inside a completely different container, outside of the backend container's environment.
- The Question: So, how do we get these two separate containers to talk to each other? What is the solution?
We can fix this in two ways:
- Either we pass the IP address of the container on which MySQL is running.
- Or we use
host.docker.internalas a special DNS name. This allows code running inside a container to talk to a service running directly on your computer (the host machine).
Let's see both ways one by one and then decide which is better
1. IP Address approach
So we need to get the IP address of the MySQL container
Run the following command to get the IP address of the MySQL container
Note: This approach works regardless of whether our database is exposed or not to our host machine.
docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mysql-container
Breaking it down:
-
docker inspect: This command digs into Docker and returns a massive JSON object filled with low-level details about your container. -
-f(or--format): Because the default output is hundreds of lines long, we use this flag to filter it. It uses Go templates to extract exactly what we are looking for. -
'{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}': This is the Go template syntax. It tells Docker to look inside theNetworkSettings, loop through the attachedNetworks, and print out just the rawIPAddressstring. -
<container_name_or_id>: Replace this with the actual name or ID of your database container.
Once you run the above command, you will get the IP of mysql-container
Copy the IP from the terminal and change DB_HOST from localhost to <IP ADDRESS> in your .env file.
DB_CONNECTION=mysql
DB_HOST=172.17.0.2
DB_PORT=3306
DB_DATABASE=my_testing_db
DB_USERNAME=root
DB_PASSWORD=myrootpassword
ALLOWED_ORIGIN=http://localhost:8080
Once you update your' .env' file, try restarting the backend container with the docker run command.
Please delete the older container or change the name of the container in the command.
I already deleted the older container, so I'm running the command with the same container name.
docker run -it -p 5500:5500 --env-file ./.env --name my-todo-backend-container my-todo-backend-image:v1
🤞
Hurray, it's working now. If it successfully started, your terminal now looks like this
Now try out this URL http://localhost:5500/api/todos in your browser
You will see seeded todos in the response
Ok, we've seen the IP approach. But the IP address approach is not good because IP is Temporary. If your computer restarts Docker, assign a new IP address when the container restarts. That will crash our backend. So we go with the second approach.
2. Use of host.docker.internal as a special DNS name
Note: This approach only works if your container is exposed to the host machine; otherwise, this approach also does not work. We exposed the MySQL container to port 3306 on our host machine, so this approach works in our case.
First, check if MySQL is running or not. It is exposed to port 3306.
To check it. Go to Docker Desktop. And in your MySQL container row, check the Port column.
Go to your .env and replace the localhost value with our special DNS name.
Replace 172.17.0.2 with host.docker.internal
DB_CONNECTION=mysql
DB_HOST=host.docker.internal
DB_PORT=3306
DB_DATABASE=my_testing_db
DB_USERNAME=root
DB_PASSWORD=myrootpassword
ALLOWED_ORIGIN=http://localhost:8080
Now, before running our container again. Please delete the older backend container that we created; otherwise, Docker will throw an error. Or you can change the name of the Docker container in the command. I'm using the same container name.
Run Docker container via the following command
docker run -it -p 5500:5500 --env-file ./.env --name my-todo-backend-container my-todo-backend-image:v1
Open this link in your browser: http://localhost:5500/api/todos
You will see the same result as we got above. You will see dummy seeded todos.
Hurray, we're done with this approach as well.
But which approach do we choose for our production?
Because the first approach we discussed is not reliable. If the IP address of the container changes, our backend breaks.
Or if we go with the second approach, then we need to expose our database, then only. We can connect with the database. But in production, we will not expose our DB. It's run inside the container without exposing to the outer world.
Then what should we do now? 🤔
We will talk about it later. But before that, we need to resolve a very important problem that exists with our current setup.
That is a Data persistence issue. We will discuss it in the next section.
6. Persisting MySQL Data in Docker Containers: Secure your MySQL data against container destruction.
Prefer Video? Watch Here
By default, Docker containers are ephemeral or temporary.
If your MySQL container stops, updates, or gets removed, all the data inside it vanishes.
To make your database robust and production-ready, you must implement a reliable data persistence strategy.
We saw the theory above; now let's try it out.
If your frontend container is running, go to http://localhost:8080/
If you are not running a frontend container, you can also use Postman to create some dummy todos. By using the URL http://localhost:5500/api/todos.
I created two todos
Now we delete our MySQL container.
First, we need to stop the container and then delete it
To stop the container run
docker stop mysql-container
Note: Once the MySQL container stops. Your backend container also crashed and stopped due to a DB connectivity issue.
Now to delete the container run
docker rm mysql-container
Now we again create a new MySQL container.
Run command
docker run --name mysql-container \
-e MYSQL_ROOT_PASSWORD=myrootpassword \
-e MYSQL_DATABASE=my_testing_db \
-p 3306:3306 \
-d mysql:8.0
Once the MySQL container has started. Go to Docker Desktop and start your backend container as well.
Once the backend container starts. Go to your frontend in the browser, go to the URL http://localhost:8080/.
You will not be able to see the todos we created previously. Only dummy-seeded todos present that are generated on the startup of the backend container.
See our data lost due to the removal of our container. So how do we persist our data?
How can we make our MySQL data persistent?
Here Docker Named Volumes comes to rescue us to avoid this condition.
Think of a Named Volume like an external USB hard drive for your container.
Normally, a container is like a temporary sandbox—if you delete the container, everything inside the sandbox is permanently destroyed.
A Named Volume solves this by permitting Docker to create a special, safe folder on your actual computer. You "plug" this volume into your container. Now, whenever your database saves information, it gets written directly to that safe folder instead of the temporary sandbox.
If the container crashes or gets deleted, your data stays completely safe on your computer, ready to be plugged right back into a brand new container.
Now let's see this in action
First, things first, stop the MySQL container and delete it. Similarly to what we did above, or you can do it from Docker Desktop. I'm using Docker Desktop to delete it.
Once the MySQL container is deleted. Firstly, we need to create volume.
To create a volume run command
docker volume create mysql_db_data
Command Breakdown
This command tells Docker to set up a brand new, empty storage space on your host machine.
Here is the exact breakdown of what each word does:
-
docker: Calls the main Docker program. -
volume: Tells Docker you want to interact with its storage management system. -
create: The specific action you want to take (make a new one). -
mysql_db_data: The custom name you are giving this specific volume so you can easily find it and attach it to containers later.
Analogy: This is like taking a brand new, blank USB flash drive and putting a sticker on it that says
mysql_db_dataso you know exactly what it's for.
Now run your MySQL container with the mount flag.
docker run --name mysql-container \
-e MYSQL_ROOT_PASSWORD=myrootpassword \
-e MYSQL_DATABASE=my_testing_db \
-p 3306:3306 \
--mount source=mysql_db_data,target=/var/lib/mysql \
-d mysql:8.0
Volume Attachment Breakdown
-
--mount: The modern, more readable flag for attaching volumes. -
source=mysql_db_data: Tells Docker to use the named volume you created previously. -
target=/var/lib/mysql: The directory inside the MySQL container where the database actually writes its data.
Once your MySQL container has started. Now start your backend container as well.
Hope your frontend container is running. Go to your frontend at http://localhost:8080/.
And create some dummy todos again. I created two new todos. Which comes after our seeded todos?
So, to check whether the data persists or not. We need to remove the container. And again run the container with --mount flag.
I'm first stopping the MySQL container and then deleting it.
Running both commands together
docker stop mysql-container
docker rm mysql-container
Now again run the same command to run the MySQL container with the --mount flag.
docker run --name mysql-container \
-e MYSQL_ROOT_PASSWORD=myrootpassword \
-e MYSQL_DATABASE=my_testing_db \
-p 3306:3306 \
--mount source=mysql_db_data,target=/var/lib/mysql \
-d mysql:8.0
Once the MySQL container has started. Start your backend container as well. Once your backend container has started.
Now go to your frontend. You will be able to see the todos we created previously are present.
We resolved the data persistence problem for our project.
Now we move back to our older problem.
For production, how can we connect our backend reliably with our MySQL database without exposing it outside?
Note: In production, we use Kubernetes to manage our containers.
But we are trying to simulate the same behaviour.
To solve our problem, we need to set up Custom Docker Networks. So that our backend can talk securely with the MySQL database.
7. Isolating Services with Docker Networks: Use custom internal networks to securely connect your backend to MySQL.
Prefer Video? Watch Here
- By default, Docker containers cannot talk to each other easily.
- However, when you place them on the same custom network, Docker provides an automatic internal DNS.
- This allows your backend to talk to your database using the container's name, completely securely, without ever publishing ports to your host machine.
Step 1: Create a Custom Network
First, create an isolated internal network for your application services to share.
docker network create my-todo-network
Step 2: Start the MySQL Container (Securely) inside a custom network
Spin up your MySQL container. Notice that we omit the -p 3306:3306 flag here. We also attach it to the network using --network flag that we just created.
Note: I'm also changing container name of our mysql db container. Also please stop the mysql-container. Other wise this container will not start. We are trying to use same volume that's used by another container. We can use it only after stopping it.
docker run -d \
--name mysql-secure-container \
--network my-todo-network \
-e MYSQL_ROOT_PASSWORD=myrootpassword \
-e MYSQL_DATABASE=my_testing_db \
--mount source=mysql_db_data,target=/var/lib/mysql \
mysql:8.0
Because there is no -p flag, nothing on your host machine (or the internet) can access this database. It is entirely locked inside Docker's internal environment.
Step 3: Start Your Backend Container
We start our backend inside the same network where our Mysql Db present.
To securely connect with the MySQL database. We can use the database container's name as the host. So instead of using localhost or an IP address. We will use the database container's name that is mysql-secure-container as the host inside the .env file.
Go to the .env file and change the DB_HOST value from host.docker.internal to mysql-secure-container.
DB_CONNECTION=mysql
DB_HOST=mysql-secure-container
DB_PORT=3306
DB_DATABASE=my_testing_db
DB_USERNAME=root
DB_PASSWORD=myrootpassword
ALLOWED_ORIGIN=http://localhost:8080
Now start the backend container. But before starting it. Please delete the older backend container from Docker Desktop.
Once you have deleted the older backend container, run the following command
docker run -d \
--name my-todo-backend-container \
--network my-todo-network \
--env-file ./.env \
-p 5500:5500 \
my-todo-backend-image:v1
Once your backend is up and running, check the URL http://localhost:5500/api/todos.
You will see previously created todos.
Also, go to your frontend and check whether it's working or not.
Hurray, we're done. We securely connected with the MySQL Database without exposing it. If you follow this blog till here, pat on the back.
Now what's next?
Let's recap what we've accomplished so far using the Docker CLI:
- Built and Deployed: We successfully built our application images and manually ran both our backend and MySQL database containers.
- Secured Data: We configured Docker Named Volumes to guarantee our database records persist, even if the container is destroyed.
- Isolated Communication: We established a custom Docker network to ensure our services (frontend, backend, and database) can talk to each other securely without exposing sensitive ports to the host machine.
While this manual CLI approach gives you a great understanding of how Docker works under the hood, it introduces a massive margin for human error. If a single flag is missed, a port is mapped incorrectly, or a network name is misspelled in your terminal, the entire system breaks.
Right now, we are only managing three moving parts: a frontend, a backend, and a database. But imagine if you were working with a microservices architecture that had ten, twenty, or fifty distinct services. How could you possibly start, link, and manage them all by typing out docker build and docker run commands manually for every single service?
The short answer is: you wouldn't. In the next part of this series, we will look at how to solve this exact problem and automate our entire architecture using Docker Compose.
8. Automating with Docker Compose: Spin up your complete architecture, including networks and volumes, with just one command.
Prefer Video? Watch Here
Why Do We Need Docker Compose? (The Problem)
Imagine your full-stack web application relies on three distinct services to function:
- Frontend: React
- Backend: Node.js (Express)
- Database: MySQL
Without Docker Compose, you are forced to type out long, error-prone commands in your terminal every single time you start your development environment:
# 1. Create the isolated network
docker network create my-net
# 2. Run the database container with volume mounts
docker run -d -p 5500:5500 --network my-net --name db mysql:8.0
# 3. Run the backend API container
docker run -d --network my-net --name backend my-node-app
# 4. Run the frontend container and map the public port
docker run -d -p 3000:80 --network my-net --name frontend my-react-app
If you forget a single environment flag, misconfigure a port, or mistype a network name, your services fail to communicate and your entire application breaks. Managing this manually becomes a nightmare as your architecture grows.
The Solution: What is Docker Compose?
Docker Compose is a tool that allows you to define and run multi-container Docker applications. Instead of memorizing and executing four different docker run commands every time you want to code, you define your entire infrastructure once inside a single blueprint file: docker-compose.yml (or compose.yaml).
By defining your architecture as code, you can launch your entire ecosystem—frontend, backend, and database—with just one simple command instead of juggling multiple complex terminal scripts:
docker compose up -d
Key Advantage: Automatic Networking 🌐
One of the best features of Docker Compose is that it handles all the networking for you automatically.
The moment you start your compose file, it instantly creates a private, secure network just for your application services.
Here is why that makes your life easier:
- No More IP Addresses: You don't have to keep track of changing container IP addresses anymore.
- Simple Hostnames: Your backend service can talk to your database securely using just the database's service name (the name you give it in your YAML file) as the hostname.
Docker completely handles all the complicated internal routing dynamically behind the scenes.
Let’s Write Our Docker Compose File
Navigate to your backend folder and create a file named compose.yml.
And then paste below code inside it
services:
# Your Database Service
my-todo-database:
image: mysql:8.0.44 # or mysql
restart: always
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
volumes:
- mysql_db_data:/var/lib/mysql
# Your App Service
todo-backend:
build: .
restart: on-failure
ports:
- "5500:5500"
env_file:
- .env
depends_on:
my-todo-database:
condition: service_healthy
volumes:
mysql_db_data:
external: true
Docker Compose Configuration Explained
This docker-compose.yml file sets up a robust, two-tier architecture consisting of a database and a backend application. Here is the line-by-line breakdown of what this configuration tells Docker to do.
The Services Block
services:
The root level of the Compose file. It defines all the different containers (services) that make up your application.
The my-todo-database Service
my-todo-database:
Names the first service. This name acts as the hostname on the internal Docker network (so the backend can connect to it using my-todo-database as the host).
image: mysql:8.0.44
Pulls the official MySQL image from Docker Hub, specifically version 8.0.44, instead of building it from a local Dockerfile.
restart: always
Tells Docker always to restart this container if it stops, crashes, or if the server itself reboots.
environment:
Begins the block for setting environment variables inside the MySQL container.
MYSQL_DATABASE: ${DB_DATABASE}
Creates a default database on startup. The value is dynamically injected from a variable named DB_DATABASE (usually found in an adjacent .env file).
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
Sets the MySQL root user password, also securely injected from the DB_PASSWORD variable.
healthcheck:
Configures a test to determine if the container is actually ready to handle requests, not just running.
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
The command Docker runs inside the container to check its health. Here, it pings the MySQL server to see if it is actively accepting connections.
timeout: 20s
If the mysqladmin ping command takes longer than 20 seconds to return a result, Docker considers that specific check a failure.
retries: 10
Docker will try the health check 10 times. If it fails 10 times consecutively, the container is officially marked as "unhealthy".
volumes:
Begins the block for mounting storage to the container.
- mysql_db_data:/var/lib/mysql
Maps a named Docker volume (mysql_db_data) to the path inside the container where MySQL stores its raw data (/var/lib/mysql). This ensures your database records survive even if the container is destroyed or recreated.
The todo-backend Service
todo-backend:
Name your application service.
build: .
Instead of pulling a pre-made image, this tells Docker to build a new image using the Dockerfile located in the current directory (.).
restart: on-failure
Unlike the database, this container will only restart if it exits with a non-zero exit code (indicating a crash or error). It won't restart if you intentionally stop it gracefully.
ports:
Begins the block for mapping network ports.
- "5500:5500"
Maps port 5500 on your host machine (your laptop or server) to port 5500 inside the container. This allows external traffic (like your browser or a frontend app) to reach your API.
env_file:
Begins the block for loading bulk environment variables.
- .env
Tells Compose to load all key-value pairs from a local .env file and inject them into the backend container.
depends_on:
Controls the startup order of your services.
my-todo-database:
Specifies that the backend relies on the my-todo-database service.
condition: service_healthy
This is the most crucial part of the file. It tells Docker Compose not to start the backend container until my-todo-database has successfully passed its healthcheck. This prevents your backend from crashing on startup due to trying to connect to a database that is still booting up.
The Volumes Block
volumes:
The top-level configuration for volumes used by the services above.
mysql_db_data:
Declares the volume name referenced by the database service.
external: true
Tells Docker Compose that this volume was created manually outside of this Compose file (e.g., a dev ran docker volume create mysql_db_data in the terminal). Also, compose, not try to create a new volume. It will use this volume.
Please stop all the previously running containers. Otherwise, port conflicts will occur except for the frontend container.
Running our Docker Compose
First, go to your backend .env file. And change the value of DB_HOST from mysql-secure-container to my-todo-database.
DB_CONNECTION=mysql
DB_HOST=my-todo-database <-- change this
DB_PORT=3306
DB_DATABASE=my_testing_db
DB_USERNAME=root
DB_PASSWORD=myrootpassword
ALLOWED_ORIGIN=http://localhost:8080
Then, in the terminal, go to the backend directory and run the Docker Compose command.
docker compose up
docker compose up reads your compose.yml file to automatically build, network, and start all your application's containers in the correct order.
Once you run this command, immediately go to Docker Desktop, and you will see MySQL started, but your backend app has not started yet.
But after some time, it automatically gets started. If no error occurs.
After some time, when you see something like below in your CLI, your backend has started successfully
If you see seeding data skipped. Why, because we are using our older volume that we created previously.
Once both the DB and backend container start, go to http://localhost:5500/api/todos, you will be able to see our previously created todos also present.
Also, check your frontend as well. By navigating to http://localhost:5500/api/todos
If you can see our previously created todos. We had done with this setup successfully. Hope you enjoyed it. You can learn more about Docker Compose. Now this blog post is over. If you are interested to setup frontend as well with Docker Compose, follow along with the blog.
9. Bonus Section: Add a React frontend to complete your stack.
Prefer Video? Watch Here
Go to compose.yml that we created previously. Here inside the service section. We just need to add our frontend service as well that's it.
after todo-backendservice paste following code snippet
todo-frontend:
build: ../frontend
ports:
- "8080:8080"
env_file:
- ../frontend/.env
Frontend Service
todo-frontend:
Names your frontend application service. This creates a dedicated container for your UI (like React or Vue) on the same Docker network.
build: ../frontend
Tells Docker to build the container image using the Dockerfile located in the frontend directory. This keeps your frontend build logic cleanly separated from your backend.
ports:
Begins the port mapping configuration.
- "8080:8080"
Maps port 8080 on your local machine to port 8080 inside the container. You will view your app by visiting http://localhost:8080 in your browser, while the internal web server (like Nginx) listens on the HTTP port 8080.
env_file:
Begins the block for injecting environment variables.
- ../frontend/.env
Loads UI-specific environment variables (like your backend API URL) from a dedicated .env file inside the frontend folder.
That's it now save the compose.yml file and run the docker compose command inside the backend directory.
After adding frontend service in your compose.yml. It's finally looks like
services:
# Your Database Service
my-todo-database:
image: mysql:8.0.44 # or mysql
restart: always
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
volumes:
- mysql_db_data:/var/lib/mysql
# Your App Service
todo-backend:
build: .
restart: on-failure
ports:
- "5500:5500"
env_file:
- .env
depends_on:
my-todo-database:
condition: service_healthy
todo-frontend:
build: ../frontend
ports:
- "8080:8080"
env_file:
- ../frontend/.env
volumes:
mysql_db_data:
external: true
Note: Hope you stopped all the previously running container.
Run docker compose command
docker compose up
Above command run by default in interactive mode. Which hijack your terminal. If you want to run your container in detach mode. you can use -d detached flag.
After running above command you will see all three services spin up. You can check in docker desktop.
Now also check your frontend by visiting http://localhost:8080/.Hope you're able to see our todos.
Our frontend is working fine. As expected.
10. Wrap Up:
We're done with our full-stack todo app deployment using Docker. If you follow along till here, give yourself a gift. Because you really did great work.
From here, you can learn more about Docker Compose. Or you can start learning about Kubernetes. And use this image to run inside the pod in Kubernetes. There is much more to explore.
If this helps you in any way. Please like and share your valuable comment with some love. Hope you like it👋.






























Top comments (0)