Docker in development: Episode 2

The last episode was about motivation and the benefits of using docker for development. In this episode, we’ll dockerize a Ruby on Rails app.

Before we start:

  • You must be familiar with docker (as mentioned in the last episode) and docker-compose.

  • We know there are tons of “Dockerizing X” tutorials/blog posts out there. It’s not the goal of this post to present you the absolute tutorial/blog post, but to show you the problems we encountered while taking our first steps using docker, and how to solve them (as those aren’t easily googleable).

Step 1: Create a Dockerfile

As you know, the first step is to create a Dockerfile in the root directory of our Rails app. The Dockerfile explains how the app is built and how it will be executed. For a standard Rails app, we usually have (more or less) this Dockerfile:

FROM ruby:2.6

RUN apt-get update -qq && apt-get install -y build-essential curl git libpq-dev nodejs

WORKDIR /app

COPY . /app
COPY bin/entrypoint.sh /usr/bin/

RUN chmod +x /usr/bin/entrypoint.sh

RUN bundle install

ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]

If you’re familiar with docker (and we encouraged that since the beginning of the post), you’ll understand all of it. There’s nothing out of the ordinary in this Dockerfile, but one thing that you wouldn’t find in a regular Rails project is the entrypoint. The entrypoint is the main command of the image and is often used in combination with helper scripts.

In our case, for a Rails app, a normal bin/entrypoint.sh file would look like this:

#!/bin/sh

set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /app/tmp/pids/server.pid

exec "$@"

Let’s start from the end of the script. $@ means “All of the parameters passed to the script”. If we look at the Dockerfile above, that is rails server -b 0.0.0.0, meaning that exec rails server -b 0.0.0.0 will be executed within the entrypoint. The question here is: Why do we need the entrypoint then? And the answer is that container might have a pidfile that could cause the cmd to fail. That’s why rm -f /app/tmp/pids/server.pid needs to exist in the entrypoint.

Are we ready to build our image and start running our app? No, we aren’t. We still haven’t defined what our database is, where it is, and we haven’t set up our config/database.yml file. In our case, we’ll use a Postgres database. The reason why we didn’t do that yet is because we don’t want the database to be part of our app setup. If we do so, for example, by installing it in our image, then our data won’t be persistent across different executions (technically, you can achieve that by mounting volumes and such, but it goes against best practices). This is where docker-compose comes into play.

version: "3"

services:
  db:
    image: postgres
    volumes:
      - ./db/postgresql-data:/var/lib/postgresql/data
  web:
    build: .
    depends_on:
      - db
    ports:
      - 3000:3000
    volumes:
      - .:/app

Step 2: docker-compose

As mentioned before, we don’t want our database to run separately from our codebase. In a typical Rails app, we’d have this docker-compose.yml file in the root directory of our Rails app:

Again, if you’re familiar with docker-compose, it’s very easy to understand. We defined 2 services, the app itself (web) and the Postgres database (db). The key here is that we’re mounting 2 volumes: . (our code) will be mounted in /app in our container, and ./db/postgresql-data will be mounted in our db service in /var/lib/posgresql/data. This is important as we want our db to be persisted across executions. In other words, we don’t want to run rails db:create db:migrate every time we start our containers. Also, be aware that you don’t want ./db/postgresql-data to be versioned, so make sure you ignore it.

The next step is to set up config/database.yml. We’ll do it this way:

default: &default
  adapter: postgresql
  pool: 15
  timeout: 5000
  username: postgres
  host: db

development:
  <<: *default
  database: app_development

test: &test
  <<: *default
  database: app_test

production:
  <<: *default
  database: app_production

Note that this was redacted to keep it short. The important bits here are the username, set to postgresql, and the host, set to db. Nothing here is random: postgres is the username the postgres image defined for the username, and db is the name of the service defined in our docker-compose.yml file. If you want to find out more about the postgres image, you can do that by reading Postgres’ official image docs.

Are we ready to build our image and start running our app? Technically, yes, but let’s do one more thing.

Step 3: .dockerignore

You might guess what a .dockerignore file is. If you thought it was a file that ignores the files and directories listed there when copying from the current context to the image, then you guessed correctly. We don’t want certain files to be copied onto our image to keep it small. In our typical Rails app, we’d have the following .dockerignore file:

.git
db/postgresql-data
log

Are we ready to build our image and start running our app? Yes! Now we are.

Step 4: Building and running

Now that everything is in place we can build our image and run our containers!

You can build your images and start the app by running:

$ docker-compose up --build

or the same command without --build, as it would do it by default if the images don’t exist.

By the time it finishes, you should be up and running, and able to see your app at localhost:3000.

Join us in the next episode!