In the previous article about docker(Part 1) we learnt what docker is and saw it in action by dockerizing an application that consists of Rails, Vue.js and Flask apps. We created a docker network and attached all three containers to it.

Running such a multi-container Docker application is much easier with Compose and in this article we’ll use it to run the same setup, plus learn how to utilize it for local development.

What is Docker Compose?

Compose is a tool for defining and running multi-container Docker applications.

To use it you need to create a .yml file, then define and configure all of your services there. In our case, we have Rails, Vue.js and Flask services.

Then instead of running multiple run commands:

docker run rails_container ...
docker run vue_container ...
docker run flask container ...

you can start all services with a single command:

docker-compose up

Use Docker Compose to run your Rails, Vue.js and Flask apps

Prerequisites

  1. Ruby 2.6.2
  2. Minimum of 10 GB of free disk space
  3. Docker Desktop

Firstly, clone the git repository that we used in the previous tutorial about docker. We’ll use it as a base and make it runnble via docker-compose

Before adding the YAML file, let’s also add postgres as our database, so that the setup feels more like a real project:

cd todos-api && echo "gem 'pg', '~> 0.18.4'" >> Gemfile
bundle install

and modify config/database.yml of the Rails app accordingly:

development:
  adapter: postgresql
  encoding: unicode
  pool: 5
  database: development
  host: postgres9.6
  password: ''
  username: postgres
  port: 5432

Now let’s create the docker-compose.yml file in the root folder containing all services: docker-compose.yml in root folder

And here’s what docker-compose.yml should contain. Go ahead and copy it:

version: '3.8'
services:
  postgres9.6:
    image: postgres:9.6
    environment:
      - POSTGRES_HOST_AUTH_METHOD=trust

  todos-synchronizer:
    build:
      context: todos-synchronizer/.
    depends_on:
      - postgres9.6

  todos-api:
    build:
      context: todos-api/.
    environment:
      - SYNCHRONIZER_URL=http://todos-synchronizer:5000
    ports:
     - "127.0.0.1:3005:3000"
    depends_on:
      - todos-synchronizer

  todos-app:
    build:
      context: todos-app/.
    ports:
     - "127.0.0.1:8080:8080"
    depends_on:
      - todos-api

We’ve defined 4 services here:

  • postgres9.6
  • todos-synchronizer
  • todos-api
  • todos-app

When running the whole multi-container application docker-compose creates a network and the name of each service serves as a hostname (within the network).

Hence we’ve set http://todos-synchronizer:5000 as environment variable for the todos-api service and specified postgres9.6 as the database host in database.yml

build: indicates the path where the Dockerfile of the service resides, so that its image can be build if needed.

depends_on: indicates which other services should be running before the service is started

ports: and environment: are equivalent to docker run -p-e … options

Now let’s start our app:

docker-compose up -d

with -d we start the the services in the background

and create the database:

docker-compose exec todos-api rails db:setup

alternatively you can also use:

docker ps # get the todos-api container id
docker exec -it {container_id} rails db:setup

With docker exec you can execute commands in a running container. Another commonly used one is:

docker exec -it {container_id} bash

which connects you to the container similarly to ssh

Congrats! Now you should be able to add todos on localhost:8080 🚀

With docker-compose ps you can list containers: docker-compose-ps

To stop the the application and remove containers:

docker-compose down

Other useful docker-compose commands:

 # run single service
 docker-compose run {service_name_here}
 # view output from a service container
 docker-compose logs {service_name_here}
 # build or rebuild image for a service
 docker-compose build {service_name_here}

At this point the app should be working fine and you should be able to turn it on and off as you please 😉

There’s one problem though! Creating new containers for your application will wipe out your data. Don’t panic - volumes to the rescue.

What are Docker Volumes?

The official definition reads Volumes are the preferred mechanism for persisting data generated by and used by Docker containers. Simply put that means you’re able to save the data, generated by containers, outside of them. You can also think of volumes as the ‘bridge’ that connect containers to the outside world.

For our Todos app that means we’ll be able to save todos whether we run the app in new containers or not. Let’s add a volume to the postgres9.6 service in docker-compose.yml:

...
  postgres9.6:
    image: postgres:9.6
    environment:
      - POSTGRES_HOST_AUTH_METHOD=trust
    volumes:
      - ./todos-api/tmp/db:/var/lib/postgresql/data
...

/var/lib/postgresql/data is the default location for the database files which postgres uses to store data. You can map it to any suitable directory from the host system(host system is the system or the machine that runs/hosts the containers). Now the postgres data files will be mirrored to ./todos-api/tmp/db and will be kept in sync.

Great, now your todos will persist reagadless of how many times you turn the app on and off 😉

A question that might appear is: Okay, if a volume is the way to persist data, where was it stored when we hadn't specified a volume 🤔 Great question!

If a volume isn’t defined postgres creates its own internal annonymous volume. When removing containers volumes are not removed automatically, so you probably want to remove the dangling volumes and clear some disk space:

# list docker volumes
docker volume ls
# remove one volume
docker volume rm {volume_name_here}
# or remove all unused local volumes
docker volume prune

Use docker compose for local development

Thanks to volumes we can also develop applications more easily. We can map the folders containing the source code within containers to the source code folders in the host system. That way any change we make locally will reflect on the service containers, too. Let’s extend todos-api and todos-app in docker-compose.yml:

todos-api:
    ...
    volumes:
      - ./todos-api/.:/todosapi

  todos-app:
    ...
    volumes:
      - ./todos-app/.:/todosapp

To enable hot reloading for the Rails service we also need to disable a setting in development.rb:

# Use an evented file watcher to asynchronously detect changes in source code,
# routes, locales, etc. This feature depends on the listen gem.
config.file_watcher = ActiveSupport::EventedFileUpdateChecker

ActiveSupport::EventedFileUpdateChecker somehow blocks picking up new changes that are synchronized over the volume, so we need to comment that line, then rebuild and rerun the app:

# if compose app is running
docker-compose down
# rebuild todos-api image
docker-compose build todos-api
# rerun the app
docker-compose up -d

Congrats again! Open localhost:8080 and see your code changes going live 🚀

Important! Have in mind that is a simple setup, depending on the exact development needs, other optimizations can also be applied relaying to cache, node_modules, gems, etc.

Clean up

docker-compose down
docker system prune --volumes

Thank you for sticking with me again. I hope you learnt something new today that will come in handy at some point 😊