DEV Community

Aldo Vázquez
Aldo Vázquez

Posted on

Set up a multi-container app with docker compose

Usually, we use Docker to build a whole application environment in different machines, this can be on an AWS instance, a VM, or even on your localhost.

Of course, you can write a giant Dockerfile to run all the components of your application, database, backend, web server configuration, even a cache service, this is a solution, with some disadvantages:

  • Building the image will take so long
  • If any component requires an update, the whole image will be rebuilt
  • Debugging any component will be a nightmare

Well, the other option is to build many Dockerfiles to actually manage multiple containers, this solution of course solves all the issues above, since we can work with each component individually.

Yet not everything is perfect, now we’ll need to handle many containers manually, run docker run ... for each component, creating a network to connect all our containers, setup many environment variables, and passing aliases to each container to communicate them is quite another nightmare.

How do we fix this? Maybe creating an init.sh file with each docker run ... instruction is a suitable approach, but there is another solution offered by Docker to handle this kind of situations, it is called docker compose.

docker compose

Compose is a tool for defining and running multi-container applications. It allows us to define all the services in a single file called docker-compose.yml file and then start, or stop, all of them with a single command.

Installation

Most of the newer versions of Docker Desktop already comes with docker compose command, although, you can always check the installation instructions at their official GitHub repository.

Post’s code

For this docker compose tutorial, I will use a simple REST API written in Go with a MySQL database. Since the app’s functionality is not in our scope I won’t explain it, yet if you have any question related to the example app, please don’t hesitate to reach me out by email or on Twitter.

You can find all the code used in this post here

docker-compose.yml

In the repository for this post you will find a file named docker-compose.yml this file will be the “entry point” for our application, let’s take a look into it.

version: "3.7"

services:
  db:
    build: ./db
    platform: linux/x86_64
    container_name: todos-db
    environment:
      - MYSQL_ROOT_PASSWORD=root
      - MYSQL_DATABASE=todos
    image: db:1.0
    volumes:
      - type: volume
        source: db-volume
        target: /data/mysql

  app:
    depends_on:
      - db
    build: ./app
    container_name: todos-api
    environment:
      - DB_URI=root:root@tcp(db:3306)/todos?parseTime=true
    image: api:1.0
    ports:
      - "80:8080"

volumes:
    db-volume:
        name: db-volume
Enter fullscreen mode Exit fullscreen mode

The docker-compose.yml schema requires the docker compose schema version at the top of the file, the latest version is 3.8 but any 3 version will work for this project in particular, if you are eager to learn more about the changes between versions, the change-log is published here.

Immediately, we need to define our services, each of them require a name, for example, my database service is named db this names also works as aliases within the network.

Let’s take a closer look at db service.

db:
  build: ./db
  platform: linux/x86_64
  container_name: todos-db
  environment:
    - MYSQL_ROOT_PASSWORD=root
    - MYSQL_DATABASE=todos
  image: db:1.0
  volumes:
    - type: volume
      source: db-volume
      target: /data/mysql
Enter fullscreen mode Exit fullscreen mode

Ok, now field by field:

  • build specifies which Dockerfile to use to create this service, in the project’s repository, we have a db folder, and within in we have a Dockerfile with some customizations for our database
  • platform is not actually necessary, but a good tip in case you are working with an Apple Silicon Processor.
  • container_name as you might have guessed, specifies the name of the container created, is equivalent to docker run -n flag
  • environment allows us to specify environment variables used by the container, is equivalent to docker run -e flag
  • image is for specifying the tag for this image. Is equivalent to docker build -t flag.
  • volumes is a list of the docker volumes being used by this container

Pretty straightforward, right? There are many more fields that can be specified, docker compose allows customizing each aspect of the containers, this configuration is a pretty simple, but solid, one.

But we have another service, app is for defining my REST API application container, let’s take a look at it.

app:
  depends_on:
    - db
  build: ./app
  container_name: todos-api
  environment:
    - DB_URI=root:root@tcp(db:3306)/todos?parseTime=true
  image: api:1.0
  ports:
    - "80:8080"
Enter fullscreen mode Exit fullscreen mode

And again, let’s explore each field:

  • depends_on is a quite useful field, it allows us to make our container wait until the service specified here is up, since we depend on a database to store To-dos, it is important to wait until the db service is up.
  • build again, receives the context to build a new image, within the app folder we have a Dockerfile to build the API
  • container_name as you might have guessed, again, is for naming our container
  • environment allows us to specify environment variables used in our containers, this time we only use one variable, but as you can see, the URI points to db:3306 remember when I said this names also works as aliases within the network? docker compose creates a network with each service connected to it; therefore, we can point each service using its alias.
  • image again is for tagging our images
  • ports is a list of the ports exposed by this container and the mapping to the host’s actual ports, is equivalent to docker run -p flag

docker compose up

Ok, but now what? As I said at the beginning of this post, docker compose allows us to run all our containers with a single command, this command will build all the images (if they don't exist) for our application and also run all the required containers.

But that’s not it, as I mentioned, it also creates a new docker network and attach all the containers to it, same thing for all the volumes specified in the volumes field at docker-compose.yml.

Let’s then run it using the -d flag to run our containers in the background.

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

If the images don’t exist, all the building log will be displayed; otherwise, the output should be something similar to:

[+] Running 2/0
 ⠿ Container todos-db   Running                                                                                                                   0.0s
 ⠿ Container todos-api  Running   
Enter fullscreen mode Exit fullscreen mode

As you can see, the *.container_name field is already reflected here.

Then if you list your images you should see something like:

$ docker image ls
REPOSITORY   TAG       IMAGE ID       CREATED             SIZE
api          1.0       b5c4cf711443   49 minutes ago      18.1MB
db           1.0       53c16ac987e5   About an hour ago   517MB
Enter fullscreen mode Exit fullscreen mode

Again, the field *.image was used to name our images and add the version of the image to it.

But let’s see our containers! The output will be something similar to:

$ docker ps
CONTAINER ID   IMAGE     COMMAND                  CREATED         STATUS         PORTS                  NAMES
806258079a07   api:1.0   "./server"               4 minutes ago   Up 4 minutes   0.0.0.0:80->8080/tcp   todos-api
d01357663cea   db:1.0    "docker-entrypoint.s…"   4 minutes ago   Up 4 minutes   3306/tcp, 33060/tcp    todos-db
Enter fullscreen mode Exit fullscreen mode

Where you can see the port mapping that we used in the app.ports field the name in *.container_name fields and even the *.image name.

Finally, let’s take a look at our Docker networks!

$ docker network ls
NETWORK ID     NAME                    DRIVER    SCOPE
f8d14f7ddf0b   dockercompose_default   bridge    local
Enter fullscreen mode Exit fullscreen mode

There is a new network, being used for this specific project, and we didn’t need to do nothing! It was automatically created and all the containers are already attached to it.

By the way, you can test the API with these commands:

# Create a new TODO
curl -X POST http://127.0.0.1/todos -H "Content-Type: application/json"  -d '{"description": "Write a comment!"}'

# Get TODOs
curl -X GET "http://127.0.0.1/todos"
curl -X GET "http://127.0.0.1/todos/1"

# Mark as complete
curl -X PUT "http://127.0.0.1/todos/1?completed=1"

# Delete a TODO
curl -X DELETE "http://127.0.0.1/todos/1"

Enter fullscreen mode Exit fullscreen mode

Conclusion

docker compose is a great tool, even it is not the best option to be used in production environments, is really useful to replicate different environments in different hosts, quick, easy to read and easy to share. I encourage you to build your next project using a docker-compose.yml file!

Note from the OP

Hi! Long time no see, I recently moved so I was very very busy, but I’m back with many ideas and drafts for new posts!

Thank you so much for all your support and reading my content, I’ll really appreciate any feedback or suggestion!

Latest comments (0)