Rails on Docker

Container ship carrying a train
Image by Annie Ruygt

Rails 7.1 is getting an official Dockerfile, which should make it easier to deploy Rails applications to production environments that support Docker. Think of it as a pre-configured Linux box that will work for most Rails applications.

That means you’ll start seeing a Dockerfile in the project directory of a lot more Rails apps. If you’re not familiar with Docker, you might open the file and see a few things that look familiar like some bash commands, but some other things might be new and foreign to you.

Let’s dive into what’s in a Dockerfile so its less of a mystery, but first let’s have a look at how Fly.io uses Docker so you better understand how Docker fits into a Rails stack.

How does Fly.io use Docker?

Fun fact! Fly.io doesn’t actually run Docker in production—rather it uses a Dockerfile to create a Docker image, also known as an OCI image, that it runs as a Firecracker VM. What does that mean for you? Not much really. For all practical purposes you’ll describe your applications’ production machine in a Dockerfile and Fly.io transparently handles the rest.

The great thing about Dockerfiles is it makes standardizing production deployments possible, which for most developers means its easier to deploy applications to hosts that support Docker, like Fly.io.

What’s a Dockerfile? It’s a text file with a bunch of declarations and Linux commands that describe what needs to be installed and executed to get an application running. This file is given to a bunch of fancy software that configures a Linux distribution to the point where it can run your application.

You can think of each command in the file as a “layer”. At the bottom of the layer is a Linux distribution, like Ubuntu. Each command adds another layer to the configuration until eventually all the packages, configurations, and application code are in the container and your app can run. This layering is important for caching commands, which make deployments fast if done correctly.

Let’s get into it.

A closer look at the Rails Dockerfile

At the time of this writing, the default Rails 7.1 Dockerfile looks like:

# Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.2.0
FROM ruby:$RUBY_VERSION

# Install libvips for Active Storage preview support
RUN apt-get update -qq && \
    apt-get install -y build-essential libvips && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man

# Rails app lives here
WORKDIR /rails

# Set production environment
ENV RAILS_LOG_TO_STDOUT="1" \
    RAILS_SERVE_STATIC_FILES="true" \
    RAILS_ENV="production" \
    BUNDLE_WITHOUT="development"

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install

# Copy application code
COPY . .

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile --gemfile app/ lib/

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

At the top of the file, we set the Ruby version.

# Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.2.0
FROM ruby:$RUBY_VERSION

The version gets plugged into the FROM command, which ends up looking like FROM ruby:3.2.0. Where is ruby:3.2.0? It’s a Docker image that some community members have graciously configured for us that gets us a Linux distribution running Ruby 3.2. That’s not enough to run a Rails application; we need to add a few more layers to the image.

Next up the Dockerfile installs Linux packages needed to run certain Rails gems in Linux. libvibs is a native library used to resize images for ActiveSupport. Other packages could be added here, like a Postgres, MAQL, or SQLite client. Other gems may depend on Linux packages too. For example, a popular XML parsing library, Nokogiri, depends on libxml. Those are not included in this list because the ruby image already includes them.

# Install libvips for Active Storage preview support
RUN apt-get update -qq && \
    apt-get install -y build-essential libvips && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man

apt-get is a Linux package manager that always looks strange in a Dockerfile because of all the command it does before and after installing a package. Let’s break it down line-by-line.

First, apt-get update -qq tells Linux to download a manifest of all the packages that are available for download from apt-get.

The second line is the one you care about and might need to change. apt-get install -y build-essential libvips installs two packages and the -y automatically answers “yes” when it asks if you’re sure you want to install the packages.

Everything after that removes the manifest files and any temporary files downloaded during this command. It’s necessary to remove all these files in this command to keep the size of the Docker image to a minimum. Smaller Dockerfiles mean faster deployments.

Next the working directory is set.

# Rails app lives here
WORKDIR /rails

This creates the ./rails folder inside the docker image. All of the lines in the Dockerfile after this are run from that directory and any files added to the image are put in that directory. It’s the equivalent of mkdir -p ./rails && cd ./rails.

Next a few environment variables are set.

# Set production environment
ENV RAILS_LOG_TO_STDOUT="1" \
    RAILS_SERVE_STATIC_FILES="true" \
    RAILS_ENV="production" \
    BUNDLE_WITHOUT="development"

What are these you ask?

  • RAILS_LOG_TO_STDOUT - Rails log output is sent to STDOUT instead of a file. STDOUT, or standard out, makes it possible for docker logs to view the output of whatever is running on the container. The Twelve-Factor App has a good explanation of why logs should be written to STDOUT.
  • RAILS_SERVE_STATIC_FILES - This instructs Rails to not serve static files. It’s always been recommended to have a server like nginx serve up images, CSS, JavaScripts, and other static files by a server that’s not Ruby for better performance.
  • RAILS_ENV - Instructs Rails to boot with production gems and with the configuration from config/environments/production.rb.
  • BUNDLE_WITHOUT - If you’ve ever looked in your application’s Gemfile, you’ll notice there’s gems tagged with the development group like web-console. These gems are not needed or wanted in a production environment because they would either slow things down, not be used, or pose a security risk. This command tells bundler to leave out all the development gems.

Time to install the gems! First Docker copies the Gemfile and Gemfile.lock from our workstation or CI’s server project directory into the containers ./rails directory (remember the thing that was set by WORKDIR above? This is it!)

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install

bundle install is run against the Gemfiles that were copied over. This installs the gems inside the container, which we’ll need for our Rails application to run.

Something you might be asking yourself, “why not copy the entire application from my workstation and then run bundle install?”. Great question! Each “ALLCAPS” directive in a Dockerfile, like RUN, COPY, etc. are “layers” that get cached. If you didn’t handle copying the Gemfile and running bundler as a separate layer, you’d have to run bundle install every time you deployed Rails, even if you didn’t change the gem. That would take forever!

Making it a separate layer means you only have to update the bundle when the Gemfile changes. In other words, if you only change application code, you can skip running bundle and jump right into the next layer, which saves loads of time between deploys.

Finally the Rails application code files are copied from your computer or CI machine to the WORKDIR set above, which is ./rails in the image.

# Copy application code
COPY . .

When we boot the Docker image and the Rails server, we want it to come online as quickly as possible so our deploys are faster, so the image copies over the bootsnap cache to make that happen.

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile --gemfile app/ lib/

At this point all the files needed to run the server are copied over from the workstation to the Docker image, with the exception of files listed in .dockerignorewhich typically include the .git directory, log files, etc.

Now its time to compile JavaScript, stylesheet, and image assets!

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile

This is a bit if a hack. Rails requires a secret key to keep sessions and other cryptographic Rails features secure, but for an asset compilation, including the actual secret key is not needed and is therefore a liability. Instead SECRET_KEY_BASE_DUMMY=1 is passed into the compilation task to tell Rails, “ignore requiring a secret key”.

The most important part of this command is bundle exec rails assets:precompile, which runs whatever compilation steps are needed to minify and fingerprint assets so they load quickly in production.

The ENTRYPOINT directive in Docker acts like a wrapper.

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

The best thing to do is look at the contents of the ENTRYPOINT script, which lives at ./bin/docker-entrypoint

#!/bin/bash

# If running the rails server then create or migrate existing database
if [ "${*}" == "./bin/rails server" ]; then
  ./bin/rails db:prepare
fi

exec "${@}"

The script checks to see if the CMD, below, is running ./bin/rails server. If its running the server, it will make sure it runs a database migration before it boots the application. If you don’t want Rails to automatically run migrations when you deploy, you could comment out or remove the ENTRYPOINT directive in the Dockerfile.

The last thing in all Dockerfiles is how to boot the application server.

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

EXPOSE 3000 tells Docker the application will listen on port 3000, which is the default for bin/rails server, as you see in your Rails development environment.

Take it for a spin

You can expect a lot of changes between now and when Rails 7.1 is released. For example, we’re currently exploring extracting Dockerfile generation out of railties and moving it into its own gem at https://rubygems.org/gems/dockerfile-rails. That means you can use this today with your current Rails project. If you want to try it out, first install the gem to your Rails app.

$ bundle add dockerfile-rails

Then generate the Dockerfile with the dockerfile command.

$ ./bin/rails dockerfile

Then checkout the Dockerfile that’s now at the root of your project. You can then deploy it by installing Fly.io, running the following command, and following the instructions.

$ fly launch

Fly.io will ask you a few questions and within a few minutes, you should see your Rails app running in production.

There’s a lot of different ways to configure a Dockerfile

The official Rails Dockerfile will be a great starting place for most people, but as applications grow in complexity and the need to install additional packages arises, it might not be enough.

Fly.io has started putting together a collection of Dockerfile recipes in the Fly Rails Cookbooks. You’ll find example Dockerfiles for all sorts of different Rails deployments including those that needs Node 19+ installed or for Rails API deployments.