Rails on Docker: Using Docker Compose with Your Ruby on Rails Apps

In the previous tutorial, you learned how to get a simple Ruby on Rails app up and running on Docker containers.

As you work on your app, it often becomes difficult (and tiresome) to remember all the different configurations and arguments you need to create and link containers. The complexity increases as your app's dependencies grow (such as when you need to using Sidekiq and to handle background jobs).

Thankfully, Docker comes with a great solution to this problem in the form of Docker Compose.

Compose allows you to declare your app's requirements and configuration a simple yaml file. From this file, Docker will create, configure and start the containers. You can also use Compose to specify data volumes for your containers, and connect containers across an isolated virtual network.

In this tutorial, we'll take the simple Rails app from last time and use Compose to replicate the container setup. This will make creating an "instance" of our Rails app much faster, and allow configurationt to be easily shared between developers.


Need the code? If you don't have the Rails application code from last time, you can create it as new Rails app using the following template:

$ rails new --database=postgresql --skip-bundle --template=https://gist.githubusercontent.com/cblunt/1d3b0c1829875e3889d50c27eb233ebe/raw/8fce95533db3a3be19cb1aa31054589d219433c2/rails-docker-pg-template.rb my-app

Creating a Compose File

The simple rails app we built makes use of two containers: one for the app itself and one for the PostgreSQL database. To run them, we created and linked two containers using the following commands:

$ cd my-app
$ docker build . -t my-app
$ docker run -d -it --env POSTGRES_PASSWORD=superSecret123 --env DB_NAME=my-app_development --name mydbcontainer postgres:9.6
$ docker run --rm -it --env RAILS_ENV=development --env POSTGRES_USER=postgres --env POSTGRES_PASSWORD=superSecret123 --publish 3000:3000 --volume ${PWD}:/app --link mydbcontainer:db my-app

We'll recreate this setup in a new docker-compose.yml file:

# ./my-app/docker-compose.yml
version: '3'

services:
  db:
    image: postgres:9.6
    environment:
      - POSTGRES_PASSWORD=superSecret123
      - DB_NAME=my-app_development
  web:
    build: .
    environment:
      - RAILS_ENV=development
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=superSecret123
    ports:
      - '3000:3000'
    volumes:
      - .:/app
    depends_on:
      - db

Notice that while the db container uses the postgres:9.6 image, the web container (which is our app) will build a new docker image from the existing Dockerfile in your app's folder.

You can see that the YAML file reflects the command-line arguments we previously supplied to Docker. However, it is much easier to read and manage!


ProTip: What are Services?

Rather than specify each individual container, in Compose we specify services. Each service (db, web in the file above) can be thought of as representing one or more containers that running a particular Docker image.

Services allow us to create any number of identical containers running the same application. This is important when we come to scale services (i.e. run 2 or more copies of our Rails application and load-balance between them).

For this tutorial, we'll only be using one container per service so you can think of each service as a single container.


Starting Services

Next we'll use Docker Compose to create and start the containers (services) specified in the docker-compose.yml file. Compose will automatically download or build any images it needs to create each container:

$ docker-compose up -d
Creating network "myapp_default" with the default driver
Creating myapp_db_1 ... done
Creating myapp_web_1 ... done

Once your image is built, Compose will create and start the containers necessary for your app to run. The -d flag detaches your console allowing the containers to run in the background. You can tail the log messages from the containers just like you would with docker, but using the docker-compose command instead.

Compose will tail the logs for all of the containers specified in the docker-compose.yml file:

$ docker-compose logs -f
Attaching to myapp_web_1, myapp_db_1
web_1  | Puma starting in single mode...
...
web_1  | * Listening on tcp://0.0.0.0:3000
web_1  | Use Ctrl-C to stop
...
db_1   | LOG:  database system was shut down at 2017-09-09 11:20:25 UTC
db_1   | LOG:  MultiXact member wraparound protections are now enabled
db_1   | LOG:  database system is ready to accept connections
db_1   | LOG:  autovacuum launcher started

Hit [Ctrl-C] to stop tailing the logs. You can also show or tail logs from just one of the containers by using the name given to it in docker-compose.yml. For example, to tail only our app's logs:

$ docker-compose logs -f web
web_1  | Puma starting in single mode...
...
web_1  | * Listening on tcp://0.0.0.0:3000
web_1  | Use Ctrl-C to stop

Hit [Ctrl-C] to exit the logs (note if you omit the -f flag, the logs command will automatically exit)

You can also see the app is running by opening your browser and visiting localhost:3000.

Running Single-Task Containers with Compose

Just as before, your app will throw a missing database error. This is because the container that was created by Compose is a completely separate instance from the previous container (remember that containers are ephemeral. As soon as they are removed, any data within them is lost).

As in the previous tutorial, we'll need to run a database migration task to create our app's database inside the db container. Just like normal Docker, we can use Compose to run create, use and remove a single-task container. Compose will take care of creating, configuring and running any dependent containers (e.g. the database container) as specified in the docker-compose.yml file before running the task.

To create and migrate our database, use the docker-compose run command (again, this is nearly identical to the standard docker run command:

$ docker-compose run --rm web bin/rails db:create db:migrate
Starting myapp_db_1 ... done
Created database 'my-app_development'
Created database 'my-app_test'
== 20170909110604 CreatePosts: migrating ======================================
-- create_table(:posts)
   -> 0.0221s
== 20170909110604 CreatePosts: migrated (0.0223s) =============================

Specifying the --rm flag tells Compose to remove the container once the task has finished. Notice that although we used the web specification from our docker-compose.yml file, Compose created, used and removed a completely separate container to run this task. The previous instance of our web container is still running:

$ docker-compose ps
   Name                  Command               State           Ports
-----------------------------------------------------------------------------
myapp_db_1    docker-entrypoint.sh postgres    Up      5432/tcp
myapp_web_1   bundle exec puma -C config ...   Up      0.0.0.0:3000->3000/tcp

Now if you revisit localhost:3000, you'll see the familiar Posts screen.

Stopping and Tidying Up Containers

Compose takes care of tidying up containers for us just as easily as creating them. You can stop and start containers in the normal way. This will stop but not remove them, allowing them to be restarted with data intact:

$ docker-compose stop
Stopping myapp_web_1 ... done
Stopping myapp_db_1 ... done

# Visiting localhost:3000 will now show a connection refused error as the containers are not running.

$ docker-compose start
Starting db ... done
Starting web ... done

To stop and completely remove the containers (including all their data), use the docker-compose down command:

$ docker-compose down
Stopping myapp_web_1 ... done
Stopping myapp_db_1 ... done
Removing myapp_web_1 ... done
Removing myapp_db_1 ... done
Removing network myapp_default

(Note that you can also stop and remove individual service containers using, for example, docker-compose stop web and docker-compose rm web).

Using docker-compose down will also remove any other unnecessary resources that were created, such as the virtual network (in this case myapp_default). Data volumes (which we've not used here) are not removed by default, allowing you to persist data between runs.

Use Docker Compose in your Apps

Compose is a great tool, and forms the basis of more advanced Docker tools (such as configuring stacks for Docker Swarm orchestration).

You can see how using Docker Compose greatly simplifies the configuration of your containers. By specifying the various services necessary for your app in a single place, it is easy to create disposable instances of your app for development, and share configuration with other developers.

Adding a docker-compose.yml file to your repository is a great way to make getting started with your app as easy as possible. Often, your README file for people to get started using your app can be as simple as:

$ git clone https://github.com/your-app.git $ cd your-app $ docker-compose run --rm web bin/rails db:setup $ docker-compose up -d $ open http://localhost:3000/

Next Steps

Thanks for reading this tutorial. I hope you've found it useful and you can begin using Docker and Docker Compose in your own projects. Let me know how you get on by getting in touch.

Next time, we'll explore how you can use additional services for your Rails apps with Docker compose, such as Sidekiq and redis.