18  Use Docker Compose

Docker Compose is a tool that was developed to help define and share multi-container applications. With Compose, we can create a YAML file to define the services and with a single command, can spin everything up or tear it all down.

The big advantage of using Compose is you can define your application stack in a file, keep it at the root of your project repo (it’s now version controlled), and easily enable someone else to contribute to your project. Someone would only need to clone your repo and start the compose app.

So, how do we get started?

18.1 Check Docker compose installation

  • Verify that Docker Compose is installed correctly by checking the version.
docker compose version

This should output something like Docker Compose version v2.17.3

If you don’t get any output, install Docker compose.

  • Update the package index:
sudo apt-get update
  • Install the latest version of Docker Compose:
sudo apt-get install docker-compose-plugin

18.2 Create the Compose file

  • At the root of the /getting-started/app folder, create a file named docker-compose.yml:
touch docker-compose.yml
  • Open the file in the VS Code Explorer.

18.3 Define the app service

To remember, this was the command we were using to define our app container.

docker run -dp 3000:3000 \
  -w /app -v "$(pwd):/app" \
  --network todo-app \
  -e MYSQL_HOST=mysql \
  -e MYSQL_USER=root \
  -e MYSQL_PASSWORD=secret \
  -e MYSQL_DB=todos \
  node:18-alpine \
  sh -c "yarn install && yarn run dev"
  • In the compose file, we’ll start off by defining the list of services (or containers) we want to run as part of our application.
services:
  • Let’s define the service entry and the image for the container. We can pick any name for the service. The name will automatically become a network alias, which will be useful when defining our MySQL service.
services:
  app:
    image: node:18-alpine
  • Typically, you will see the command close to the image definition, although there is no requirement on ordering. So, let’s go ahead and move that into our file.
services:
  app:
    image: node:18-alpine
    command: sh -c "yarn install && yarn run dev"
  • Let’s migrate the -p 3000:3000 part of the command by defining the ports for the service. We will use the short syntax here, but there is also a more verbose long syntax available as well.
services:
  app:
    image: node:18-alpine
    command: sh -c "yarn install && yarn run dev"
    ports:
      - 3000:3000
  • Next, we’ll migrate both the working directory (-w /app) and the volume mapping (-v "$(pwd):/app") by using the working_dir and volumes definitions. Volumes also has a short and long syntax. One advantage of Docker Compose volume definitions is we can use relative paths from the current directory.
services:
  app:
    image: node:18-alpine
    command: sh -c "yarn install && yarn run dev"
    ports:
      - 3000:3000
    working_dir: /app
    volumes:
      - ./:/app
  • Finally, we need to migrate the environment variable definitions using the environment key.
services:
  app:
    image: node:18-alpine
    command: sh -c "yarn install && yarn run dev"
    ports:
      - 3000:3000
    working_dir: /app
    volumes:
      - ./:/app
    environment:
      MYSQL_HOST: mysql
      MYSQL_USER: root
      MYSQL_PASSWORD: secret
      MYSQL_DB: todos
  • Copy and paste all of the contents in your file and save the changes.

18.4 Define the MySQL service

Now, it’s time to define the MySQL service. The command that we used for that container was the following:

docker run -d \
  --network todo-app --network-alias mysql \
  -v todo-mysql-data:/var/lib/mysql \
  -e MYSQL_ROOT_PASSWORD=secret \
  -e MYSQL_DATABASE=todos \
  mysql:8.0

We will first define the new service and name it mysql so it automatically gets the network alias. We’ll go ahead and specify the image to use as well.

services:
  app:
    # The app service definition
  mysql:
    image: mysql:8.0

Next, we’ll define the volume mapping. When we ran the container with docker run, the named volume was created automatically. However, that doesn’t happen when running with Compose. We need to define the volume in the top-level volumes: section and then specify the mountpoint in the service config. By simply providing only the volume name, the default options are used. There are many more options available though.

services:
  app:
    # The app service definition
  mysql:
    image: mysql:8.0
    volumes:
      - todo-mysql-data:/var/lib/mysql

volumes:
  todo-mysql-data:
  • Finally, we only need to specify the environment variables.
services:
  app:
    # The app service definition
  mysql:
    image: mysql:8.0
    volumes:
      - todo-mysql-data:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: todos

volumes:
  todo-mysql-data:

At this point, our complete docker-compose.yml should look like this:

services:
  app:
    image: node:18-alpine
    command: sh -c "yarn install && yarn run dev"
    ports:
      - 3000:3000
    working_dir: /app
    volumes:
      - ./:/app
    environment:
      MYSQL_HOST: mysql
      MYSQL_USER: root
      MYSQL_PASSWORD: secret
      MYSQL_DB: todos

  mysql:
    image: mysql:8.0
    volumes:
      - todo-mysql-data:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: todos

volumes:
  todo-mysql-data:

18.5 Run the application stack

Now that we have our docker-compose.yml file, we can start it up!

  • Make sure no other copies of the app/db are running first (sudo docker ps and sudo docker rm -f <ids>). Remove all of them.

  • Start up the application stack using the docker compose up command. We’ll add the -d flag to run everything in the background.

sudo docker compose up -d

When we run this, we should see output like this:


[+] Running 4/4
  Network app_default           Creat...                       0.1s 
  Volume "app_todo-mysql-data"  Created                        0.0s 
  Container app-mysql-1         Sta...                         0.8s 
  Container app-app-1           Start...                       0.8s 

You’ll notice that the volume was created as well as a network! By default, Docker Compose automatically creates a network specifically for the application stack (which is why we didn’t define one in the compose file).

  • Let’s look at the logs using the sudo docker compose logs -f command.

You’ll see the logs from each of the services interleaved into a single stream. This is useful when you want to watch for timing-related issues. The -f flag “follows” the log, so will give you live output as it’s generated.

The service name is displayed at the beginning of the line (often colored) to help distinguish messages. If you want to view the logs for a specific service, you can add the service name to the end of the logs command (for example, sudo docker compose logs -f app).

  • At this point, you should be able to open your app and see it running.

18.6 Tear it all down

When you’re ready to tear it all down, simply run docker compose down for the entire app. The containers will stop and the network will be removed.

::: {.callout-caution}

18.7 Removing Volumes

By default, named volumes in your compose file are NOT removed when running docker compose down. If you want to remove the volumes, you will need to add the --volumes flag.

18.8 Next steps

In this section, you learned about Docker Compose and how it helps you dramatically simplify the defining and sharing of multi-service applications. You created a Compose file by translating the commands you were using into the appropriate compose format.

At this point, you’re starting to wrap up the tutorial. However, there are a few best practices about image building you should cover, as there is a big issue with the Dockerfile you’ve been using.