Docker basics: running multi-container application using docker compose

Without using docker-compose, we usually run our container using our terminal/CMD one by one. Connecting and running our container from every flag that we use in our terminal command. It's not a wrong method to run our container. But we have a risk of wrong typing and suddenly our container is not running as it should be. It can be whether we forgot to attach a network to it or using the wrong port. Many things can go wrong, and that's why many people preferring docker-compose to run a multi-container app.

Using docker-compose for our app

For this section, we're going to use the last blog post app. It's a very simple app that communicates with each other over the same network. I conclude it's the best and simplest app for our needs here. So let's start by creating the docker-compose file.

In our previous app directory, please get into the root directory of docker-network folder and create a file called docker-compose.yml. After that, please paste this code here inside the file:

version: "3.8"

services:
    anime:
        build: ./anime-image
        image: anime-image
        container_name: animeimage
        ports:
            - "8080"
        networks:
            - anime-net

    web:
        build: ./anime-web
        image: anime-web
        container_name: animeweb
        ports:
            - "3001:8081"
        networks:
            - anime-net
        depends_on:
            - anime
        environment:
            - IMAGE_URL=http://animeimage:8080/anime

networks:
    anime-net:
        name: nat

As we can see here, the docker-compose file is using the YAML language syntax. If you already know JSON beforehand, it shouldn't be hard for you to understand what's written here.

On the first line, we can see that we specify the version. That version corresponds with a specific docker release. So by using a different version, we may get different sets of feature and syntax. We can check all the list of docker-compose versions here.

The second thing that we see here is services. We should know that services here is different from the container, in fact, services are a lists of all the components that make up the application, services could be run at scale with several containers from the same image.

Inside the services we have two service. The first service is the anime service, and the second service is the web service. Inside each service, we build our image directly using directory path, and then also giving it an image and container name. We also run each service on the same network, so they can communicate with each other.

The only difference here between the two services is the fact that the anime service doesn't map any port to its host and the fact that web service has an environment variable for it. Leaving aside the anime service, we all know from the previous article that our main app needs an environment variable for it to run.

As for why we don't map anime port to the host port is because we don't need it. We can access the anime service using its name as we can see from the environment variable, so we really don't need the host port. Communication between service under the same networks only using the port of the service, not the host. So by doing it like this, docker will map the service port to any random port on the host.

The depends-on that's inside web service will tell docker to wait for anime service to run, before running the web* service, because that service is depending on another service.

As for the networks itself, docker-compose will create it manually from our last three line. We give it a name of nat and plug it into each service that needs it.

Now if we run the command:

docker-compose up

We'll see that docker-compose will take care of each image building and running so that we don't have to specify any command in the terminal. And if access localhost:3000 like our previous article, we can see that our app is running without an error.

We can see the value here. Just the fact that we can run our entire app using only one terminal command is really valuable. Another thing we have to realize is that docker-compose can be served as some kind of documentation of our app. From seeing the docker-compose file, we can see any configuration, env variable, how many containers, networks, that our app needs in order to run.

Running todo app using docker-compose

Our previous app may not be representing a real app. A general app is using a database, so we need to create and dockerize an app with a database. And that's why we're going to create a simple todo app. The app will only show a simple page with input and button inside of it (without any css), and also the list of our input saved into the database.

So I conclude that we can use express js and mongo db for that.

Let's start by creating a new directory somewhere in our PC called docker-todo. Inside of that directory, please run this command:

npm init -y

This command will be going to generate a default package.json for our app. You're going to need node.js for that. But in the end, we're not going to use any node for our app, because docker will take care of it for us using the node.js image.

The second command that we're going to run on our terminal is this:

npm install --save express mongodb dotenv

It's the three library that we're using in order to run our app. Then please create a new file inside the docker-todo directory called app.js and paste this code inside that file:

require('dotenv').config()
const express = require('express')
const MongoClient = require('mongodb').MongoClient;

const app = express()
app.use(express.urlencoded({ extended: true }))

app.set('view engine', 'ejs')
app.set('views', __dirname + '/views')

const url = process.env.CONNECTION || `mongodb://localhost:27017`;
const dbName = 'myproject';
const port = 3000

app.get('/', (req, res) => {
    MongoClient.connect(url)
        .then(async client => {
            const db = client.db(dbName);
            const collection = db.collection('tasks')
            try {
                const tasks = await collection.find({}).toArray()
                client.close();
                res.render('index', {
                    'tasks': tasks
                })
            } catch(err) {
                console.log(err)
            }
        })
        .catch(err => {
            console.log(err)
        })
})

app.post('/todo', async (req, res) => {
    const data = req.body.todo

    MongoClient.connect(url)
        .then(async client => {
            const db = client.db(dbName);
            const collection = db.collection('tasks')
            try {
                await collection.insertOne({
                    'name': data
                })
            } catch(err) {
                console.log(err)
            }
            client.close();
            res.redirect('/')
        })
        .catch(err => {
            console.log(err)
        })
})

app.listen(port, () => {
    console.log(`Example app listening at http://localhost:${port}`)
})

I'm not going to explain in detail about the code (it's so wrong in so many places). You just have to know that our app have two route. GET / and POST /todo. The GET is the route to show our view and the POST is the route to save data to our database.

From line 9, we set our view as a file called index.ejs inside a directory called views. And because we don't have that file and directory yet, now please create it. Start by creating a directory called views and create a file called index.ejs inside of it. ejs is our default template engine, you can check more of it here. After that please paste this HTML code inside that ejs file:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>docker compose app</title>
</head>
<body>
    <div id="app">
        <div class="app-form">
            <form action="/todo" method="POST">
                <input type="text" placeholder="insert task" name="todo">
                <button>Submit</button>
            </form>
        </div>
        <div class="app-content">
            <ul>
                <% tasks.forEach(function(task){ %>
                    <li> <%= task.name %> </li>
                <% }); %>
            </ul>
        </div>
    </div>
</body>
</html>

It's just a simple HTML without even any CSS. Now we can start by building our Dockerfile and paste this instruction inside it:

FROM node:14-alpine
WORKDIR /app
EXPOSE 3000
RUN npm install
CMD ["node", "app.js"]
COPY . .

It's just a simple single-stage docker file build using node:14-alpine base image. Where we specify our working directory, port, run command, and install command. It's a very simple Dockerfile. After this, please also create docker-compose.yml file inside our root directory and paste the code below there:

version: "3.8"

services:
  db:
    image: mongo:3.6.22
    container_name: mongo
    ports:
      - "27017"
    networks:
      - app-network

  web:
    build: .
    image: todo-web
    container_name: todo-web
    ports:
      - "3000:3000"
    depends_on:
      - db
    networks:
      - app-network
    environment:
      - CONNECTION="mongodb://mongo:27017"

networks:
  app-network:
    name: nat

Let's talk about the db service first. Our db service is using mongo:3.6.22 image which will be pulled from the docker hub. We give it a container name of mongo, map the port to random host port, and plug a network to it in order to communicate with the web service later.

The web service is built from the root folder, that's why we're using . for the build path. We give it a container name of todo-web. We also map the exact same port as the container port to the host. This service will depend on the db service, so it will wait for the db service to run first. It will connect to the same network as db service in order to communicate with it. And lastly, the environment variable. Because of the same network, we can access the db service using it's container name. So we use mongo in our environment.

Now, if we run the command of:

docker-compose up -d

We can access the localhost:3000 now and access our todo app without an error. The -d tag is for running it in the background. Now our app is running and we can try to add by clicking submit.

But let's now stop all of our services by removing our container using the command:

docker container rm containerId -f

You can get the containerId by running docker container ls --all and see the container id of each of your running container. We just have to remove the container of our todo-web and mongo container.

Now that we have remove both of our container service, we can try to run it again using the same command of:

docker-compose up -d

But we've realized that something has changed. It is that all the data that we have added is removed. That's because we remove our mongo container. When we remove mongo container, we also remove all the data inside of it. Sometimes this'll become a problem. One solution we can use is by using the docker VOLUME.