Dockerizing Ruby on Rails

Andreas Wittig – 19 Dec 2019

Did you dockerize your Ruby on Rails application already? You definitely should! Read on to learn why and how.

Shipping software is a challenge. Endless installation instructions explain in detail how to install and configure an application as well as all its dependencies. But in practice, following installation instructions ends with frustration: the required version of Ruby is not available to install from the repository, the configuration file is located in another directory, the installation instructions do not cover the operating system you need or want to use, etc.

Dockerizing Ruby on Rails

And it gets worse: to be able to scale on-demand and recover from failure, we need to automate the installation and configuration of our application and its runtime environment. Implementing the required automation with the wrong tools is very time-consuming and error-prone.

But what if you could bundle your application with all its dependencies and run it on any machine: your MacBook, your Windows PC, your test server, your on-premises server, and your cloud infrastructure? That’s what Docker is all about.

In short: Docker is a toolkit to deliver software.

The most important part of the Docker toolkit is the container. A container is an isolated runtime environment preventing an application from accessing resources from other applications running on the same operating system. The concept of a jail - later called a container - had been around on UNIX systems for years. Docker uses the same ideas but makes them a lot easier to use.

With Docker containers, the differences between different platforms like your developer machine, your test system, and your production system are hidden under an abstraction layer. But how do you distribute your application with all its dependencies to multiple platforms? By creating a Docker image. A Docker image is similar to a virtual machine image, such as an Amazon Machine Image (AMI) that is used to launch an EC2 instance. The Docker image contains an operating system, the runtime environment, 3rd party libraries, and your application. The following figure illustrates how you can fetch an image and start a container on any platform.

Distribute your application among multiple machines with a Docker image

But how do you create a Docker image for your web application? By creating a script that builds the image step by step: a so-called Dockerfile.

Next, you will learn how to dockerize a typical Ruby on Rails application.

The project structure of a typical Ruby on Rails project looks like this:

├── Gemfile
├── README.md
├── Rakefile
├── app
├── babel.config.js
├── bin
├── config
├── config.ru
├── db
├── lib
├── log
├── package.json
├── postcss.config.js
├── public
├── storage
├── test
├── tmp
├── vendor
└── yarn.lock

How to bundle an application with the described project structure? Let’s have a look at the Dockerfile:

  1. Based on Amazon Linux 2.
  2. Installs Node.js, required by yarn, required by Ruby on Rails.
  3. Installs Ruby and Ruby on Rails with all needed dependencies.
  4. Installs wait-for-it - a helper to make sure that the MySQL database is up and running before the Ruby container is started with docker-compose.
  5. Copies all files except the ignores defined in the file .dockerignore.
  6. Installs all Ruby dependencies of the application and generates the static assets.
  7. Configured a custom entry point that runs the database migrations when the container starts before the application is started.
  8. Exposes port 3000 and defines the default command to run the application.
Dockerfile
FROM amazonlinux:2.0.20190508

RUN mkdir /usr/src/app
WORKDIR /usr/src/app

# Install Node.js (needed for yarn)
RUN curl -sL https://rpm.nodesource.com/setup_10.x | bash -
RUN yum -y install nodejs gcc-c++ make

# Install Ruby & Rails
RUN curl -sL -o /etc/yum.repos.d/yarn.repo https://dl.yarnpkg.com/rpm/yarn.repo
RUN amazon-linux-extras enable ruby2.6 && yum -y clean metadata \
&& yum -y install git tar gzip yarn zlib-devel sqlite-devel mariadb-devel ruby-devel rubygems-devel rubygem-bundler rubygem-io-console rubygem-irb rubygem-json rubygem-minitest rubygem-net-http-persistent rubygem-net-telnet rubygem-power_assert rubygem-rake rubygem-test-unit rubygem-thor rubygem-xmlrpc \
&& gem install rails

# Install wait-for-it
COPY docker/wait-for-it.sh /usr/local/bin/
RUN chmod u+x /usr/local/bin/wait-for-it.sh

# Copy Ruby files (see .dockerignore)
COPY . .

# Install Ruby dependencies
ENV RAILS_ENV production
ENV RAILS_LOG_TO_STDOUT enabled
ENV RAILS_SERVE_STATIC_FILES enabled
RUN bin/bundle install --deployment --without development test
# see https://github.com/rails/rails/issues/32947 for SECRET_KEY_BASE workaround
RUN SECRET_KEY_BASE=dummy bin/rails assets:precompile

# Configure custom entrypoint to run migrations
COPY docker/custom-entrypoint /usr/local/bin/
RUN chmod u+x /usr/local/bin/custom-entrypoint
ENTRYPOINT ["custom-entrypoint"]

# Expose port 3000 and start Rails server
EXPOSE 3000
CMD ["bin/rails", "server", "--binding=0.0.0.0"]

To limit the amount of data that needs to be sent to Docker, the .dockeringore file defines an exclude list for files and directories that you typically do not need to include in the Docker image.

.dockerignore
aws/*
log/*
storage/*
tmp/*
public/assets/*
public/packs/*

It is a best practice when dockerizing applications to use environment variables instead of configuration files. Do not use files to store the configuration for your application. Use environment variables instead. Luckily, Ruby on Rails comes with it’s own configuration mechanism that supports environment variables out of the box. Check out the file config/database.rb to see how environment variables are used to configure the database connection.

config/database.rb
# ...
production:
<<: *default
host: <%= ENV['DATABASE_HOST'] %>
database: <%= ENV['DATABASE_NAME'] %>
username: <%= ENV['DATABASE_USER'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>

One more thing: it is necessary to run the database migration each time you roll out a new version of your application. The easiest way to do so is to execute db:migrate each time you start the Docker container. To do so, the Dockerfile adds a so-called ENTRYPOINT which references the shell script custom-entrypoint. Each time you start the Docker container, the entry point script gets executed as well.

The custom-entrypoint script:

  1. Waits until it is possible to establish a database connection.
  2. Executes the database migration by calling db:migrate.
  3. Starts the puma web server.
custom-entrypoint
#!/bin/bash
set -e

if [ -n "${WAIT_FOR_IT}" ]; then
wait-for-it.sh mysql:3306
fi

echo "running migrations"
bin/rails db:migrate

echo "starting $@"
exec "$@"

That’s it! You are ready to build a Docker image bundling your Ruby on Rails application.

docker build -t myapp:latest .

Start a container based on the image …

docker run -p 3000:3000 myapp:latest

… and open http://localhost:3000.

You have successfully dockerized your Ruby on Rails application. What is next?

  • Push the Docker image into a private registry (e.g., Amazon ECR).
  • Deploy your application in the cloud (e.g., with ECS and Fargate).

Andreas Wittig

Andreas Wittig

I’ve been building on AWS since 2012 together with my brother Michael. We are sharing our insights into all things AWS on cloudonaut and have written the book AWS in Action. Besides that, we’re currently working on bucketAV,HyperEnv for GitHub Actions, and marbot.

Here are the contact options for feedback and questions.