DEV Community

Cover image for How to Containerize a Full-Stack App (Part 3)-Containerizing the Backend and Mysql Database Integration
utkarsh srivastava
utkarsh srivastava

Posted on Edited on

How to Containerize a Full-Stack App (Part 3)-Containerizing the Backend and Mysql Database Integration

Series Navigation:

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
Enter fullscreen mode Exit fullscreen mode

2. Build the Docker image:

docker build -t my-frontend-secure-todo-multistage:v2 .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

6. Run the backend:
Open a new terminal and run

cd backend
npm i
npm run migrate
npm run dev
Enter fullscreen mode Exit fullscreen mode

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:

  1. Backend Code Walkthrough: A brief overview of our Backend app structure.
  2. Generating the Production Build: How to prepare the app for deployment.
  3. Deployment Architecture: High-level view of our deployment
  4. Writing multistage dockerfile for our backend: Writing the step-by-step build instructions.
  5. Building Docker Image: Building Docker Image and Running it.
  6. Persisting MySQL Data in Docker Containers: Secure your MySQL data against container destruction.
  7. Isolating Services with Docker Networks: Use custom internal networks to securely connect your backend to MySQL.
  8. Automating with Docker Compose: Spin up your complete architecture, including networks and volumes, with just one command.
  9. Bonus Section: Add a React frontend to complete your stack.
  10. 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 project file structure

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Database migration
  2. Build creation
  3. Securely providing env variables
  4. 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

package json file

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
Enter fullscreen mode Exit fullscreen mode

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

high level view of backend

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"]
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation of Docker file

  1. 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.
  2. 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 in package-lock.json. It uses specialized Docker "mounts" to cache downloaded packages and temporarily read dependency files without copying them permanently inside the image.
  3. 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 /app folder.
    • RUN npm run build: Executes the build script defined in your package.json to compile the code (usually outputting to a dist folder).
  4. 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 (the dist folder) 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 new backend group gets 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
Enter fullscreen mode Exit fullscreen mode

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 your package.json to 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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

Hope your Docker image got created. You can check inside the Docker Desktop, in the image section.

Docker desktop 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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 port 5500 on your local machine to port 5500 inside the isolated container so you can access your API via localhost: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 .env file.
  • --name my-todo-backend-container: Assigns a readable, custom name to the container. If you skip this, Docker assigns a random, quirky name (like sleepy_einstein), making it harder to find later.
  • my-todo-backend-image:v1: The blueprint. This tells Docker exactly which image and version (v1 tag) 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 -it flag for -d (detached mode)!

Once you run it, your container will crash. Oops, what happened this

error during starting the backend

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

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

.env file

The localhost Problem

  • The Setup: In our environment variables, we set DB_HOST to 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:

  1. Either we pass the IP address of the container on which MySQL is running.
  2. Or we use host.docker.internal as 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
Enter fullscreen mode Exit fullscreen mode

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 the NetworkSettings, loop through the attached Networks, and print out just the raw IPAddress string.
  • <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

Terminal o/p after running ip command

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🤞

Hurray, it's working now. If it successfully started, your terminal now looks like this

backend successfully running in terminal

Now try out this URL http://localhost:5500/api/todos in your browser

You will see seeded todos in the response

Browser output for the backend api

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.

Docker desktop port checking

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

frontend with two additional todo

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
Enter fullscreen mode Exit fullscreen mode

docker command to stop the 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
Enter fullscreen mode Exit fullscreen mode

docker remove command

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
Enter fullscreen mode Exit fullscreen mode

Once the MySQL container has started. Go to Docker Desktop and start your backend container as well.

docker desktop backend container start

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.

frontend after removal of older todos

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.

docker desktop for deleting the container

Once the MySQL container is deleted. Firstly, we need to create volume.

To create a volume run command

docker volume create mysql_db_data
Enter fullscreen mode Exit fullscreen mode

docker volume command

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_data so 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
Enter fullscreen mode Exit fullscreen mode

run mysql with volume

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?

frontend with new 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
Enter fullscreen mode Exit fullscreen mode

docker stopping and removal command

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
Enter fullscreen mode Exit fullscreen mode

docker command to run the mysql container with volume

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.

frontend with persistent todos

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
Enter fullscreen mode Exit fullscreen mode

creating custom 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
Enter fullscreen mode Exit fullscreen mode

docker run command with custom network

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

docker command for backend container

Once your backend is up and running, check the URL http://localhost:5500/api/todos.

You will see previously created todos.

whole todo flow

Also, go to your frontend and check whether it's working or not.

frontend with all todos

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:

  1. Frontend: React
  2. Backend: Node.js (Express)
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then, in the terminal, go to the backend directory and run the Docker Compose command.

docker compose up
Enter fullscreen mode Exit fullscreen mode

docker compose command

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.

docker desktop docker compose backend

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

docker compose up cli output after on backend start

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.

api output after starting backend container

Also, check your frontend as well. By navigating to http://localhost:5500/api/todos

frontend after docker compose

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Note: Hope you stopped all the previously running container.

Run docker compose command

docker compose up
Enter fullscreen mode Exit fullscreen mode

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.

docker desktop result after running compose

Now also check your frontend by visiting http://localhost:8080/.Hope you're able to see our todos.

frontend final output

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)