Containerizing an Existing Rails Application

Containers are popular because they create a reproducible environment that you can deploy to production and run locally in development. However, containerizing a Rails app can be tricky—Jeff Morhous helps us navigate the pitfalls.

Containerizing software is packaging it into standardized units for ease of development and deployment. Containers bundle together code from your application, along with all of its dependencies. A container can stand alone entirely; it contains a package with your software, a runtime environment, and system libraries. Containers help developers and operations teams ensure that software runs the same, regardless of its environment. By separating code from infrastructure, apps that are "containerized" run the same in a local environment, a test environment, and production.

Docker is one of the most popular platforms for developing and deploying software. Docker packages software as an "image", which is turned into a container at runtime when executed on the Docker Image. The isolation allows developers to run many containers on a single host at the same time.

Rails developers face a unique set of challenges when containerizing an existing application. This article will provide a walkthrough of containerizing a functional Rails app and explain the important concepts and pitfalls along the way. This article is not a basic description of containers or Docker; instead, it is an explanation of problems developers face when containerizing production applications.

Prerequisites

If you're following along, then you'll obviously need a Rails application that isn't already dockerized (that's the docker-specific term for 'containerized'). I'll be using RailsWork, a fully featured side project that I just launched. It's a job-board written with Rails and deployed to Heroku, but it isn't containerized.

Beyond that, you'll also need to have Docker installed. A popular way to install it is with Docker Desktop, which can be downloaded via the official website.

A Screenshot of the Docker Desktop Website

Once the app is downloaded, run the installer. After it runs, it will prompt you to drag the application to your applications folder. You'll then have to launch the app from your applications folder and grant it the privileged permissions it asks for. As a last check to ensure Docker is installed properly, try to list the containers running on your machine from your terminal by running the following:

docker ps

If Docker is installed (and you're not running any containers), you'll get an empty list with just headers that look like this:

CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

The Dockerfile

It's important to start off with clear terminology before we dive in too deep.

After your Rails application is "Dockerized", it will run in a container. A container stands alone, is replaceable, and is often rebuilt.

A container is built from an image. An image is a virtual snapshot of a file system paired with metadata.

A Dockerfile is source code that describes how an image should be created. Dockerfiles are often included in a Dockerized app's repository and tracked in version control along with the rest of an app.

Creating a Dockerfile is easier than it sounds! Docker gives us special syntax that abstracts away the hard work of containerizing something. First, make your way to the root directory of the app you want to containerize. Now that you're ready to start working, it's a good idea to create a new branch if you're using git. You can easily create a new branch with the name dockerize-this-app by running the following:

git checkout -b dockerize-this-app

Next, create a Dockerfile and direct it to build an image based on a Ruby application. This can be done from the command line by running the following:

echo "FROM ruby:3.0.0" > Dockerfile

Here, we're just creating Dockerfile and adding a line that specifies where to find a Ruby container image. My project uses Ruby 3.0.0, so I used the appropriate image. If you're on a different version of Ruby, it's no problem. Docker has a list of all the supported images.

Next, manually instruct Docker to create a Docker image:

docker build -t rails_work .

Here, you can replace rails_work with any name you would like for the image. Also, be sure to include the period at the end!

If you want to see that the image has been created, you can list images on your system with the following:

docker image list

This image is mostly empty, though; it doesn't currently contain our application. We can instruct it to add the code from our app by adding the following to the Dockerfile to the end:

ADD . /rails_work
WORKDIR /rails_work
RUN bundle install

This copies over the files from your application and installs the application's dependencies. (Here, you would replace rails_work with the name of your app.)

At this point, you should rerun the command to create the image:

docker image list

There's a possibility for an issue here, especially if you're doing this to an existing production application. Bundler may complain that the version of Bundler the image is attempting to use is different from the one that created the Gemfile.lock file. If this happens, you have two clear options:

  • Change the version that the image is using.
  • Delete Gemfile.lock entirely. **If you do this, make sure to pin any versions of Gems that you need at specific versions, as the lockfile will be regenerated entirely.

If your bundle install still fails, then you may need some extra installation in your Dockerfile:

RUN apt-get update && apt-get install -y shared-mime-info

If you're still experiencing issues, you may have chosen the wrong Ruby image to base off of, so it's worth starting investigations there.

Here is a good opportunity to set environment variables:

ENV RAILS_ENV production
ENV RAILS_SERVE_STATIC_FILES true

Next, add a line to expose port 3000, which is where Rails runs by default:

EXPOSE 3000

Lastly, instruct the container to open a bash shell when it starts:

CMD ["bash"]

Altogether, your Dockerfile should look like this (with the rails_work name substituted out):

FROM ruby:3.0.0

ADD . /rails_work
WORKDIR /rails_work
RUN bundle install

ENV RAILS_ENV production
ENV RAILS_SERVE_STATIC_FILES true

EXPOSE 3000
CMD ["bash"]

Docker Commands Explained

It would certainly help to have a full understanding of some of the most common Dockerfile commands.

  • FROM -> This defines what image to base off of.
  • RUN -> This executes commands inside the container.
  • ENV -> This defines environment variables.
  • WORKDIR -> This changes the directory that the container is using.
  • CMD -> Specifies what program to run when the container starts.

Docker Compose

According to Docker's documentation, "Compose" is their tool for creating (and starting) applications with multiple Docker containers. Everything needed to spin up the application's necessary containers gets outlined in YAML. When someone runs docker-compose up, the containers are created! Docker-compose lets us declaratively describe our container configuration.

Before creating your Docker Compose file, it's important to indicate to Docker what files should be excluded from the image that gets built. Create a file called .dockerignore. (Note the period!) In this file, paste the following:

.git
.dockerignore
.env

If your Gemfile is maintained by the build process, then be sure to add Gemfile.lock to the ignores above.

Next, create a file called docker-compose.yml. This is where we'll describe our container configuration. We'll start off with this for the contents of the file:

version: '3.8'
services:
  db:
    image: postgres
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - postgres:/var/lib/postgresql/data
  web:
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/Rails-Docker
    ports:
      - "3000:3000"
    depends_on:
      - db
volumes:
  postgres:

This file creates two services: one called db and the other called web. The db container will be built from a premade image intended for postgres, and you should substitute out the relevant values for POSTGRES_USER and POSTGRES_PASSWORD. You should be careful not to put production secrets in this file - see the "Managing Secrets" section below for more information on that.

The web container is built from our Dockerfile and then starts a Rails server on port 3000 at IP address 0.0.0.0. The internal port 3000 is then mapped to the actual port 3000.

And lastly, we have a Postgres volume to persist data for us.

Managing Secrets

Authenticating at build-time can be an issue for production applications. Perhaps your application seeks Gems from a private repository, or you just need to store database credentials.

Any information that is directly in the Dockerfile is forever baked into the container image, and this is a common security pitfall.

If you're using Rails' credentials manager, then giving Docker (or any host for that matter) access is relatively trivial. In the Docker Compose file, you simply provide the RAILS_MASTER_KEY environment variable. For the given compose target, you specify the key under an environment header, which you need to create if you haven't already. The docker-compose file from above would then become the following:

version: '3.8'
services:
  db:
    image: postgres
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - postgres:/var/lib/postgresql/data
  web:
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/Rails-Docker
    ports:
      - "3000:3000"
    depends_on:
      - db
    environment:
      - RAILS_MASTER_KEY=this_would_be_the_key
volumes:
  postgres:

Now, this leaves you at a crossroads. You likely want to have this file committed to source control, but you definitely don't want your master key or even your database password tracked by source control, as this would be another dangerous security issue. The best solution so far would be to utilize the dotenv gem so that you can access these credentials by proxy, storing them in a different file that isn't tracked by source control.

Running the Dockerized Application

Finally, you can run the dockerized application with the following command:

docker compose up

Believe it or not, that's it! Docker-compose makes spinning up a container easy, especially when it comes to command-line arguments.

If you want a list of running containers, simply run the following:

docker ps

If your Rails container name is web, you can execute commands on it in a rather straightforward way. For example, if you wanted to run a Rails console, all you'd need to do is run the following:

docker exec -it web rails console

If you just want a bash shell inside the container, you'd instead run the following:

docker exec -it web bash

Some More Pitfalls Like Those Listed Here

One common issue with dockerized Rails applications in production is dealing with logs. They shouldn't be in the container system long-term. Docker suggests that logs simply be redirected to STDOUT. This can be explicitly configured in config/application.rb.

Another common issue is that of mailers. If your application uses mailers, you must explicitly define the connection settings. SMTP is a perfectly fine delivery method and usually works well with defaults, but we must be careful to set the server location and other settings to match our container configuration.

If you have workers or background jobs, such as sidekiq, then you must run it in its own container.

Conclusion

Containerizing a production Rails application comes with a set of challenges, as you no doubt have seen. As your application has grown, it likely has accumulated a number of dependencies that make a migration like this challenging. Whether it's background workers, mailers, or secrets, there are established patterns to handle most pitfalls. Once the initial work of getting a production application working with Docker is complete, the ease of future changes and deploys will make the investment worthwhile.

Honeybadger has your back when it counts.

We combine error tracking, uptime monitoring, and cron & heartbeat monitoring into a simple, easy-to-use platform. Our mission: to tame production and make you a better, more productive developer.

Learn more
author photo

Jeffery Morhous

Jeff is a Software Engineer working in healtcare technology using Ruby on Rails, React, and plenty more tools. He loves making things that make life more interesting and learning as much he can on the way. In his spare time, he loves to play guitar, hike, and tinker with cars.

More articles by Jeffery Morhous
“We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release.” 
Michael Smith
Try Honeybadger Free for 15 Days
Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
Try Honeybadger Free for 15 Days
Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
Try Honeybadger Free for 15 Days
"Wow — Customers are blown away that I email them so quickly after an error."
Chris Patton
Try Honeybadger Free for 15 Days