Categories:

How to Use Docker Compose to Handle Multi-Container NodeJS Applications


Bootstrap a NodeJS & MySQL application using Docker Compose

Docker has become a tool that developers can’t live without in less than a decade since its release. Docker provides lightweight containers to run services in isolation from other processes in the system. If you haven’t yet been properly introduced to Docker, follow our introduction to Docker guide before continuing with this tutorial.

In today’s tutorial, we are going to learn how to containerize applications that require more than one Docker container with Docker Compose . Though it’s possible to set up this system with only Docker, we can complete this task more easily and efficiently using Docker Compose.

Let’s get started then. Here’s your guide to using Docker Compose with multi-container Node.js applications.

You can check the final project on GitHub .


What is Docker Compose?

Docker Compose is a tool in the Docker platform that is used for defining and running multi-container applications. For example, if your application follows the microservices architecture, then Compose is the best tool to dockerize and run each microservice in isolation and in separate containers.

Compose takes a YAML file that defines and configures settings related to each application service. You can then start and stop all the services of the application with a single command.


Plan of Attack

As I said earlier, we are going to dockerize a multi-container Node.js application with Docker Compose. The application uses two containers, one for the application API and one for running the backend database. So, the steps we are going to follow are these.

  1. Create the Node.js API with logic to store and retrieve data from the database.
  2. Create the Dockerfile.
  3. Add Docker Compose configurations to a YAML file.
  4. Start and run the application.

Prerequisites

You should download and install the following tools in your system before continuing with the tutorial.

  • Docker Community Edition
  • Docker Compose

You can also install Docker Desktop , which includes both Docker Engine and Docker Compose, instead of separate installations.


Create the Node.js Application

We are creating a simple Node.js application that manages the creation, update, and display of “product” objects. It connects to a MySQL database to store and retrieve data.

First, let’s add the code for establishing the database connection.

//db.js

const mysql = require("mysql2");

const pool = mysql.createPool({
    host: process.env.MYSQL_HOST,
    user: process.env.MYSQL_USER,
    password: process.env.MYSQL_ROOT_PASSWORD,
    database: process.env.MYSQL_DATABASE
});


//Convert pool object to promise based object
const promisePool = pool.promise();

module.exports = promisePool;

Then, we are going to define the database model for “product”.

//models/product.js

const db = require("../db");

//Create a table for products in the database if it doesn't exist at application start
!async function createTable() {
    const tableQuery = `CREATE TABLE IF NOT EXISTS products (
        id INT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(255) NOT NULL,
        price DECIMAL(8,2) NOT NULL,
        description VARCHAR(255))`;
   
    await db.query(tableQuery);    
}();

exports.findAll = async function () {
    const results = await db.query("SELECT * FROM products");
    return results[0];
}

exports.findOne = async function (id) {
    const result = await db.query("SELECT * FROM products WHERE id=?", id);
    return result[0];
}

exports.create = async function (name, price, description) {
    await db.query("INSERT INTO products(name, price, description) VALUES (?, ?, ?)", [name, price, description]);
}

exports.update = async function (id, name, price, description) {
    await db.query("UPDATE products SET name=?, price=?, description=? WHERE id=?",
        [name, price, description, id]);
}

Finally, define the API endpoints and create the application server inside the app.js file.

//app.js

require("dotenv").config();
const express = require("express");
const productModel = require("./models/product");

const app = express();
app.use(express.json());

app.get("/product", async (req, res) => {
    try {
        const products = await productModel.findAll();
        res.status(200).json(products);
    }
    catch (err) {
        res.status(500).json({message: err.message});
    }
});

app.post("/product", async (req, res) => {
    try {
        const {name, price, description} = req.body;
        await productModel.create(name, price, description);
        res.status(200).json({message: "product created"});
    }
    catch (err) {
        res.status(500).json({message: err.message});
    }
});

app.get("/product/:id", async (req, res) => {
    try {
        const product = await productModel.findOne(req.params.id);
        if (product != null) {
            res.status(200).json(product);
        }
        else {
            res.status(404).json({message: "product does not exist"});
        }
    }
    catch (err) {
        res.status(500).json({message: err.message});
    }
});

app.put("/product/:id", async (req, res) => {
    try {
        const {name, price, description} = req.body;
        await productModel.update(req.params.id, name, price, description);
        res.status(200).json({message: "product updated"});
    }
    catch (err) {
        res.status(500).json({message: err.message});
    }
});

app.listen(process.env.NODE_DOCKER_PORT, () => {
    console.log(`application running on port ${process.env.NODE_DOCKER_PORT}`)
});

We should also create a  .env file with environmental variables that are used in the application code. Some of the variables will also be useful when creating the docker-compose.yml file later.

//.env

MYSQL_USER=root
MYSQL_ROOT_PASSWORD=root
MYSQL_DATABASE=store
MYSQL_LOCAL_PORT=3306
MYSQL_DOCKER_PORT=3306

NODE_LOCAL_PORT=3000
NODE_DOCKER_PORT=3000

In addition to these files, our project folder also contains the package.json file created during project initialization.


Create the Dockerfile

Dockerfile is one of the main ingredients in the process of containerizing an application. It defines a list of commands Docker should carry out to set up the application environment.

Since we are also using docker-compose.yml, we don’t have to define all the configuration commands in the Dockerfile. Still, we should define the most basic ones to configure the Node application.

FROM node:latest
WORKDIR /app/
COPY package.json .
RUN npm install
COPY . .

Let’s understand what each of these commands means step by step.

  • FROM instruction tells Docker to install and use the image of the latest Node.js version. If you are using a language other than Node, for example, Python, then you should use this command to install an image of Python.
  • WORKDIR sets the path of the application’s working directory inside the Docker container.
  • COPY command is used to copy files from a location inside the local system to a location inside the container. So, with the first COPY command, we copy the package.json file to the container. The second COPY command copies all the files inside the project directory.
  • RUN command is used to execute a command-line instruction inside the container. In this case, we are running npm install to install the dependencies defined in package.json.

With the Dockerfile created, you can build the application image with docker build and then run it with the docker run command. But it won’t start the Node server since we didn’t pass the start command to Docker. We are also yet to create the application database. We can achieve both these tasks with Docker Compose.


Add Docker Compose Configurations

As the first step of adding Compose configurations, create the docker-compose.yml file at the root level of the project directory.

When adding configurations to the compose file, we are going to follow the compose file version 3 syntax defined by Docker. You can find all the configuration options Compose provides in this syntax reference.

Following the version 3 syntax, the basic structure of our Compose file will appear like this.

version: '3.8'
services: 
    web:

    mysqldb:

volumes:
  • Version specifies the file syntax version we are following.
  • We should define the individual services in the application that should run in isolated containers under services. Our application has two services, one for the Node application and one for the database. We’ll see how to configure each of these services in the next section.
  • The volumes section is used to list the named volumes defined when configuring individual services. Before we continue further, we should understand a little more about volumes because it is going to become an integral part of our Docker application.

What are Volumes in Docker?

Volumes provide a way to persist changes made to data when running an application inside a Docker container. It mounts a location in the local system to save data and the changes made when the container is running.

But why do we need volumes? Let’s think a bit about the application we are creating. If the database used in our application doesn’t use volumes to persist data, every record the application stores in the database will be lost to us when the container stops or restarts. To avoid this, we have to define volumes under the database service configuration.

Docker Compose provides several ways to define volumes.

volumes:
    # Just specify a path and let the Docker Engine create a volume on the local system
    - /var/lib/mysql
    
    # Map the local location of the volume to the and Docker container location.
    - ./opt/data:/var/lib/mysql
    
    # Named volume
    - datavolume:/var/lib/mysql

While volumes are defined under each service, in our Compose file, we have to list the named volumes under the top-level volumes section.


Configure the Web Service

Now, it’s time to configure the first of the two services in our application, the web service.

Here’s how we are going to set up the web service.

web:
    build:
        context: .
    env_file: ./.env
    command: npm start
    volumes: 
        - .:/app/
        - /app/node_modules
    ports:
        - $NODE_LOCAL_PORT:$NODE_DOCKER_PORT
    depends_on: 
        - mysqldb
    environment: 
        MYSQL_HOST: mysqldb

Our first config action is running the commands defined in the Dockerfile. We use build to accomplish this. We can pass the relative path to the Dockerfile using context.

In the service configuration and inside the application code, the Node application uses environmental variables defined inside the .env file. Therefore, we should specify its path under env_file.

Docker Compose uses the command we provide, npm start, to start the Node application.

Web service defines two volumes to persist data, one for the project directory and one for node_modules directory that’s created when Docker runs npm install.

Then, we use ports to map the public local port to the internal docker port. With this mapping, the local port should be used to access the application from the outside. Meanwhile, the Node server listens to the Docker port. Instead of directly providing port numbers, we provide a reference to the value stored in .env to make the code easily maintainable.

depends_on option is used to tell Docker Compose whether the current service depends on any other service defined in the Compose file. Since our Node app depends on the MySQL database to store and retrieve data we need to specify this in the configuration.

When Compose is aware of dependencies between services, it starts and stops services in the dependency order during application start and stop. Also, if you are starting only one service, Compose automatically starts the service’s dependencies.

Finally, we need to define an environment variable that’s not in the .env file. Since Docker dynamically assigns the IP address of the MySQL container, we can’t store an exact host address in the .env file. Instead, we need to assign the value of this environment variable inside the Compose file. When we pass the name of the database service, Compose automatically updates it to the IP of the database container and Node can then access the variable from the application code.


Configuring the Database Service

Database service options follow a similar pattern to the web service.

mysqldb:
    image: mysql
    env_file: ./.env
    environment: 
        MYSQL_ROOT_PASSWORD: $MYSQL_ROOT_PASSWORD
        MYSQL_DATABASE: $MYSQL_DATABASE
    ports:
        - $MYSQL_LOCAL_PORT:$MYSQL_DOCKER_PORT
    volumes:
        - mysql:/var/lib/mysql
        - mysql_config:/etc/mysql

First, we should provide the name of the MySQL database image. Next, we pass the path to the .env file as we did in the last section.

To set up a MySQL service, you need to pass a password for the root user to access the database and the name of the database as environment variables. You can find more on other environment variable options in this Docker Hub MySQL documentation .

Similar to our Node application, we map local and Docker ports for the MySQL container. Finally, we define two named volumes. mysql volume saves the data files of the database stored at the /var/lib/mysql path in the container. The mysql_config volume persists global configuration options set for MySQL.

We also need to add these two named volumes under the top-level volumes section of the Compose file.

Here’s the final docker-compose.yml file we create for our application.

version: '3.8'
services: 
    web:
        build:
            context: .
        env_file: ./.env
        command: npm start
        volumes: 
            - .:/app/
            - /app/node_modules
        ports:
            - $NODE_LOCAL_PORT:$NODE_DOCKER_PORT
        depends_on: 
            - mysqldb
        environment: 
            MYSQL_HOST: mysqldb
    mysqldb:
        image: mysql
        env_file: ./.env
        environment: 
            MYSQL_ROOT_PASSWORD: $MYSQL_ROOT_PASSWORD
            MYSQL_DATABASE: $MYSQL_DATABASE
        ports:
            - $MYSQL_LOCAL_PORT:$MYSQL_DOCKER_PORT
        volumes:
            - mysql:/var/lib/mysql
            - mysql_config:/etc/mysql

volumes:
    mysql:
    mysql_config:

Run the Application With Docker Compose

Now, all that’s left for us to do is start and run our application. With Docker Compose, it’s only a single command.

docker compose up

When you run this command in the command-line, Docker will pull the MySQL and Node images if they are not already in your system, install npm packages, set these configurations, and start running the mysqldb and web services.

If you want to run the services on the background you can use

docker compose up -d
$ docker compose up -d
[+] Running 2/0
 ⠿ Container nodejs-docker-compose_mysqldb_1  Created                                                                                0.0s
 ⠿ Container nodejs-docker-compose_web_1      Created                                                                                0.0s
Attaching to nodejs-docker-compose_mysqldb_1, nodejs-docker-compose_web_1
mysqldb_1  | 2021-05-13 13:05:28+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.25-1debian10 started.
mysqldb_1  | 2021-05-13 13:05:28+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
mysqldb_1  | 2021-05-13 13:05:28+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.25-1debian10 started.
mysqldb_1  | 2021-05-13T13:05:29.133664Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.25) starting as process 1
mysqldb_1  | 2021-05-13T13:05:29.146329Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
mysqldb_1  | 2021-05-13T13:05:29.286817Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
mysqldb_1  | 2021-05-13T13:05:29.383092Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Bind-address: '::' port: 33060, socket: /var/run/mysqld/mysqlx.sock
web_1      | 
web_1      | > nodejs-docker-compose@1.0.0 start
web_1      | > node app.js
web_1      | 
mysqldb_1  | 2021-05-13T13:05:29.483669Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
mysqldb_1  | 2021-05-13T13:05:29.483853Z 0 [System] [MY-013602] [Server] Channel mysql_main configured to support TLS. Encrypted connections are now supported for this channel.
mysqldb_1  | 2021-05-13T13:05:29.486598Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
mysqldb_1  | 2021-05-13T13:05:29.506594Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.25'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server - GPL.
web_1      | application running on port 3000

Using the docker ps command, we can see details on the running containers.

$ docker ps
CONTAINER ID   IMAGE                       COMMAND                  CREATED         STATUS          PORTS                               NAMES
8da4858ff02e   nodejs-docker-compose_web   "docker-entrypoint.s…"   2 minutes ago   Up 25 seconds   0.0.0.0:3000->3000/tcp              nodejs-docker-compose_web_1
4371bfe94b40   mysql                       "docker-entrypoint.s…"   2 minutes ago   Up 25 seconds   0.0.0.0:3306->3306/tcp, 33060/tcp   nodejs-docker-compose_mysqldb_1

With both Node and MySQL containers running, we can now use our application to create new products, get product listings, and update products.

Let’s try testing these API endpoints with Postman .

GET /product

Testing GET /product endpoint

POST /product

Testing POST /product endpoint

Stop the Application

You can stop all the running containers with a single command.

docker compose down
$ docker compose down
[+] Running 3/3
 ⠿ Container nodejs-docker-compose_web_1      Removed  0.8s
 ⠿ Container nodejs-docker-compose_mysqldb_1  Removed  1.5s
 ⠿ Network "nodejs-docker-compose_default"    Removed  0.1s

Summary

In this tutorial, we learned how to use Docker Compose to manage a multi-container Node application. It allowed us to quickly complete the task in only a few steps. Docker Compose made the process that could have been more complex if we were using just Docker much easier to follow.

With the concepts you learned in this tutorial, you can now experiment with containerizing bigger Node applications with even more services. Not only Node, though, the process you would follow for any other language platform is not that different from what we did here. So, it’s now time to go on and play with Docker and Docker Compose.

Source: livecodestream


Leave a Reply

Your email address will not be published. Required fields are marked *