ebababi
Mutters about programming et cetera

Dockerfile for Ruby on Rails Deployments

We recently climbed the train of Docker images for production deployment at work, so I found myself in need of a good tutorial on Dockerfiles. Although there is a lot of information on Dockerfile best practices, the Ruby on Rails guides I looked up were a little bit outdated for modern applications. On top of that, the Rails official images are deprecated for some time now, so the task of creating a Dockerfile became challenging. I’ll go step-by-step on what I ended up with, explaining my thought process and any issues I stumbled upon along the way.

Before diving into the Dockerfile itself, a quick reminder of an important principle of the Docker images building process: almost all intermediate steps for creating an image are persisted. Or, as Dockerfile best practices documentation puts it: “A Docker image consists of read-only layers each of which represents a Dockerfile instruction. The layers are stacked and each one is a delta of the changes from the previous layer.” That means that the size of the image can only increase by each step and never decrease, even if a step removes items from the file system. This is important to understand since it explains the chaining of multiple commands in a single RUN clause: this chaining usually prepares and cleans up the file system around its desired change.

Back to the task at hand. With a little help from the example Dockerfile of the official Rails Docker image deprecation notice, I got an initial idea on what do I have to do. Well, the beginning was easy:

FROM ruby:2.4.5

We also maintain Ruby version in Gemfile and in .travis.yml. This counts as the third place we have to write the Ruby version during upgrades… Maybe we should automate al these references at some point 🤔

Next, I dealt with Ruby on Rails dependencies. Following the Ruby on Rails installation guide, the following packages have to be installed (excluding services):

It would be as easy as listing all these packages as arguments to apt-get if the latter two (Node.js and Yarn) weren’t obsolete or absent from the Ubuntu repositories. Because of this, in order to install these two packages, it’s necessary to add to the image their Debian compatible repositories and their respective signing keys. So, the Dockerfile command that will (a) update packages lists, (b) include the repositories of Node.js and Yarn after (c) fetching their signing keys, (d) installing all dependencies, and (e) clean up the packages lists cache is:

# Install Ruby on Rails dependencies.
# https://guides.rubyonrails.org/development_dependencies_install.html#ubuntu
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        apt-transport-https \
        software-properties-common \
    && apt-key adv --keyserver "hkp://ipv4.pool.sks-keyservers.net" \
        --recv-key "9FD3B784BC1C6FC31A8A0A1C1655A0AB68576280" \
    && add-apt-repository "deb https://deb.nodesource.com/node_10.x stretch main" \
    && apt-key adv --keyserver "hkp://ipv4.pool.sks-keyservers.net" \
        --recv-key "72ECF46A56B4AD39C907BBB71646B01B86E50310" \
    && add-apt-repository "deb https://dl.yarnpkg.com/debian/ stable main" \
    && apt-get update \
    && apt-get install -y --no-install-recommends \
        nodejs \
        yarn \
        sqlite3 \
        libsqlite3-dev \
        mysql-client \
        default-libmysqlclient-dev \
        postgresql-client \
        libpq-dev \
        imagemagick \
        ffmpeg \
        poppler-utils \
    && rm -rf /var/lib/apt/lists/*

Whew, that was a long step! Fortunately, these dependencies don’t change often and, therefore, the Docker layer caching can be leveraged to speed up builds. Next, the application environment is prepared. The following Dockerfile commands will (a) set the recommended working path and (b) set environment variables for a production build:

# Use the recommended working path.
WORKDIR /usr/src/app

# Set environment variables indicating a production build.
ENV RACK_ENV production
ENV RAILS_ENV production
ENV NODE_ENV production

Cool! Now the image is ready to accept the application code and install its dependencies. But wait! Here’s one more trick: while code might change often, its dependencies wouldn’t. So how about copying only the dependencies files (Gemfile, Gemfile.lock, package.json, and yarn.lock) and then triggering the dependencies installation? This would allow docker to leverage its layers cache when the dependencies files are the same and avoid reinstalling them every time the rest of the code changes:

# Install Ruby dependencies.
COPY Gemfile* ./
RUN bundle install --without development:test --frozen --no-cache

# Install JavaScript dependencies.
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile --no-cache --production \
    && yarn cache clean

Now the image is ready to accept the application code files. Additionally, it’s advisable to set the Rails environment to some values appropriate to Docker images deployments, like enabling logging on standard output and serving static files thru the Ruby application server:

# Copy application code to the working path.
COPY . ./

# Set Rails environment variables appropriate to a Docker image.
ENV RAILS_LOG_TO_STDOUT enabled
ENV RAILS_SERVE_STATIC_FILES enabled
ENV REDIS_PROVIDER REDIS_URL

Almost ready! One more thing: the assets have to be precompiled and hashed in order to be served. Unfortunately, the assets precompilation task will bring up the whole application environment, including database connections. For this reason, the DATABASE_URL environment variable is set to a memory persisted SQLite database and the SECRET_KEY_BASE environment variable is set to a dummy value. Notice that the Dockerfile command ARG is used instead of ENV. A great difference between these two commands is that the latter persists during the container runtime, while the former ends its life cycle after the build finishes.

# Set default values to required Rails environment variables.
ARG SECRET_KEY_BASE=deca1fc2f5da822a699fffb01fffbf97bb736e9ec3720637
ARG DATABASE_URL=sqlite3::memory:

# Prepare for Rails asset pipeline.
RUN bin/rake assets:precompile \
    && bin/rake assets:clean

The image is nearly complete now. The EXPOSE command will allow access to the container on the defined port. The same environment variable is used by the Rails application server to listen there. The default command is set to start the application server and bind to all IPs:

ENV PORT 3000
EXPOSE $PORT

CMD ["bin/rails", "server", "--binding=0.0.0.0"]

Retrospectively, the Dockerfile does not seem too complicated now. But it took me some time to compile it and hopefully it will save you some time. Here’s the gist of the whole Dockerfile licensed under the BSD 2-Clause license. Please post any suggestions there. I will also update this article if any changes come up.