How to set up a Rails development environment with Docker

How to set up a Rails development environment with Docker

This is how I set up new Rails projects to make it easy for other developers and myself to work on an application.

Featured on Hashnode

Summary

Quickly set up a Rails development environment with Docker. Minimize the amount of time new developers need to set up Ruby version, dependencies, and other services required to run the codebase locally.

If you want to quickly set up your Rails app with the set up described here, check out the convenient Rails application template at https://github.com/Code-With-Rails/rails-template.

Introduction

Being a consultant for some time, a consistently difficult task is getting an application’s codebase running on my Mac.

Part of the problem is that the underlying host system changes from year-to-year. Apple, for instance, releases a new version of Mac OS and Xcode every year.

Many people use Homebrew to install dependencies such as database services. However, the installation of these services sometimes break when the OS is updated.

At the end of the day, the problem is that developers spend a lot of time troubleshooting the setup of their Rails applications, as the information in the README is out-of-date or inaccurate altogether.

This is inefficient. Fortunately, especially if you’re starting a new Rails application, there’s a better way.

In this article, I will share with you how I set up my Rails application so that it is ready to be worked on by fellow developers on most platforms.

Specifically, I target the following systems:

  • Mac OS
  • Linux (eg. Ubuntu)
  • Online IDEs like GitPod.io

Requirements

To set this up, we need to make sure you have the following already running on your local machine:

  • Ruby installed (eg. 2.7 and higher)
  • rails gem installed
  • Docker and docker-compose installed

Objectives

Our goal here is to create an easy-to-use Ruby on Rails development environment. Most of the problems we encounter as developers is making sure we have all our dependencies aligned with our production environment.

Unfortunately, many Rails projects I’ve worked on seem to suffer the same problem where the local development set up is convoluted — even in situations where Docker is used.

Ideally, we should be able to launch the app and all necessary services like so:

# Shell
docker-compose up

And to start up a terminal where we can do things like run migrations, tests, etc., we can do this:

# Shell
docker-compose run app bash

Finally, our ideal set up also allows us to use the same toolchain we’ve been using on our host system. Specifically in my case, my code editor and GUI git app (Sublime Text and Sublime Merge).

So to summarize, our set up should:

  • Easily allow me to boot up the app and all services
  • Allow me to drop into terminal in the context of all dependencies
  • Allow the continued use of existing developer tools

With all that said, let’s take a look to see how we’re going to set this up.

Generate a Rails app

First, we need a Rails application to work with. For this, we’ll run the familiar rails new command:

# Shell
rails new my-app --database=postgresql

Create some useful scripts

Next, I like to use the script directory to hold a few useful script files which get run when our Docker container starts.

Arguably, this could be added to the bin directory. However, my personal preference is to separate these out as these script files are primarily for development purposes.

Inside the script directory, we will create the following file:

wait-for-scp.sh

The wait-for-scp.sh file is used to ensure that a specific service (eg. Redis, PostgreSQL) is started during our app boot up.

Within this file, we will add the following:

  #!/usr/bin/env bash
  if [ -z "$2" ]; then cat <<'HELP'; exit; fi
  Usage: script/wait-for-tcp ADDRESS PORT
  Wait for TCP connection at ADDRESS:PORT, retrying every 3 seconds, maximum 20 times.
  HELP
  wait_for_tcp() {
    local addr="$1"
    local port="$2"
    local status="down"
    local counter=0
    local wait_time=3
    local max_retries=20
    while [ "$status" = 'down' -a ${counter} -lt ${max_retries} ]; do
      status="$( (echo > "/dev/tcp/${addr}/${port}") >/dev/null 2>&1 && echo 'up' || echo 'down' )"
      if [ "$status" = 'up' ]; then
        echo "Connection to ${addr}:${port} up"
      else
        echo "Waiting ${wait_time}s for connection to ${addr}:${port}..."
        sleep "$wait_time"
        let counter++
      fi
    done
    if [ ${status} = 'down' ]; then
      echo "Could not connect to ${addr}:${port} after ${max_retries} retries"
      exit 1
    fi
  }
  wait_for_tcp "$1" "$2"

To make this script executable, run:

# Shell
chmod +x script/wait-for-tcp.sh

docker-dev-start-web.sh

The docker-dev-start-web.sh file is used as the entry point for when our app container is started. Within this file, we will:

  • Ensure that PostgreSQL and Redis have started (so that our app boot up does not error out should the containers start out-of-sequence)
  • Run migrations and DB seed
  • Start our Procfile

Tip: You can customize this in any fashion that you like. The following content contains several opinionated choices.

  #!/usr/bin/env bash
  set -xeuo pipefail
  ./script/wait-for-tcp.sh db 5432
  ./script/wait-for-tcp.sh redis 6379
  if [[ -f ./tmp/pids/server.pid ]]; then
    rm ./tmp/pids/server.pid
  fi
  bundle
  if ! [[ -f .db-created ]]; then
    bin/rails db:drop db:create
    touch .db-created
  fi
  bin/rails db:create
  bin/rails db:migrate
  bin/rails db:fixtures:load
  if ! [[ -f .db-seeded ]]; then
    bin/rails db:seed
    touch .db-seeded
  fi
  foreman start -f Procfile.dev

To make this script executable, we need to run:

# Shell
chmod +x script/docker-dev-start-web.sh

Create Procfile.dev

Next, we’ll need to create a Procfile.dev that will be responsible for booting up our app within the app container.

# Procfile.dev
web: bin/rails server --port 3000 --binding 0.0.0.0

The above Procfile is quite simple as it only runs the web service.

A more complicated Procfile would include running a background job Ruby process. I’ll leave that out for the purpose of this tutorial as that’s easy to add.

Note also that I named the Procfile as Procfile.dev rather than Procfile. Because we call foreman with -f option, we explicitly use Procfile.dev. This helps in case you need a slightly different Procfile (and cannot change the name) for deployment reasons.

Create a Dockerfile

Almost done, but first we will create a Dockerfile for your app container.

Similar to other steps, the Dockerfile I’m about to show you is specific to my needs. Feel free to change or modify things here specific to your application’s app container.

For simplicity’s sake, I use the Ruby 3.1.2 container image.

The Dockerfile itself is very easy to understand and should be self-explanatory if you’ve used Docker before:

# Dockerfile

  FROM ruby:3.1.2-bullseye
  # Install apt based dependencies required to run Rails as
  # well as RubyGems. As the Ruby image itself is based on a
  # Debian image, we use apt-get to install those.
  RUN apt-get update && apt-get install -y \
    build-essential \
    nano \
    nodejs
  # Configure the main working directory. This is the base
  # directory used in any further RUN, COPY, and ENTRYPOINT
  # commands.
  RUN mkdir -p /app
  WORKDIR /app
  # Copy the Gemfile as well as the Gemfile.lock and install
  # the RubyGems. This is a separate step so the dependencies
  # will be cached unless changes to one of those two files
  # are made.
  COPY Gemfile* ./
  RUN gem install bundler -v 2.3.22 && bundle install --jobs 20 --retry 5
  RUN gem install foreman
  COPY . /app
  RUN rm -rf tmp/*
  ADD . /app

Briefly, we’re installing some essential system utilities Node.js. I personally prefer to use nano, so that is why I’ve added it in here.

Create docker-compose.yml

Finally, we need something to boot up our whole stack.

Again, in the root directory of our project, add the following:

# docker-compose.yml

  version: "3.8"
  networks:
    backend:
    frontend:
    selenium:
  services:
    db:
      image: postgres:14.2-alpine
      ports:
        - "5432:5432"
      environment:
        POSTGRES_USER: postgres
        POSTGRES_PASSWORD: postgres
      networks:
        - backend
    redis:
      image: redis:7-alpine
      ports:
        - "6379:6379"
      networks:
        - backend
    chrome_server:
      image: seleniarm/standalone-chromium
      volumes:
        - /dev/shm:/dev/shm
      networks:
        - selenium
    app:
      build: .
      tty: true
      volumes:
        - .:/app
      working_dir: /app
      environment:
        DB: postgresql
        DB_HOST: db
        DB_PORT: 5432
        DB_USERNAME: postgres
        DB_PASSWORD: postgres
        BUNDLE_GEMFILE: /app/Gemfile
        SELENIUM_REMOTE_URL: http://chrome_server:4444/wd/hub
      command: script/docker-dev-start-web.sh
      networks:
        - backend
        - frontend
        - selenium
      ports:
        - "3000:3000"
      depends_on:
        - db
        - redis
        - chrome_server

Our docker-compose.yml configures 4 services:

  1. PostgreSQL
  2. Redis
  3. Chrome Server (for running system tests)
  4. Our app

You may be wondering why we are installing a Chrome Server service.

The purpose of adding this service is to allow our app to run system tests.

While it is possible to install a Chrome service within our app environment, it is assumed that we will use the same Dockerfile for production.

As a result, in order to avoid the situation where we may be installing unnecessary software, we separate this out.

Configure database.yml

In order for our Rails application to connect with our database — which is hosted on a different container — we will make the following adjustment to our config/database.yml file:

# database.yml
development:
  <<: *default
  username: postgres
  password: postgres
  host: db

test:
  <<: *default
  username: postgres
  password: postgres
  host: db

Leave the database name as is (eg. my-app_test and my-app_development). Do not delete them!

Lock the Ruby version

Your setting for this may be different depending on the version of Ruby that is set up in your app container.

Specifically for the above example, we are using Ruby 3.1.2.

As a result, you'll need to update the Ruby version that's referenced in the Gemfile:

# Adjust the following line to reference Ruby 3.1.2
ruby '3.1.2'

The current default is likely set as Ruby 3.1.0.

Using the set up

With all that set up, it's time to build your containers. You can do so by running the following command, which will instruct Docker to build your app container based on your Dockerfile:

docker-compose build

Once that's ready, you can run the following to run the app:

docker-compose up

To start a shell where you can run Rails migrations, generators, and other commands, simply run:

docker-compose run app bash

Typically, I would run docker-compose up in one terminal window, and docker-compose run app bash in another.

Using a Rails template

Given that all these steps are rather repetitive, I've created a Rails Application template to handle all of this.

The repository for this template is at github.com/Code-With-Rails/rails-template.

To use this template, all you need to do is run the following command and it will generate all the necessary configurations as we've talked about in this post:

rails new your-app --skip-bundle --database=postgresql -m https://raw.githubusercontent.com/Code-With-Rails/rails-template/main/default.rb

How is this different than other Docker setups?

Many tutorials online use Docker to set up supporting services only (eg. PostgreSQL, Redis, etc), but still rely on running the Rails application within the host system or modify the Dockerfile to run the app itself in its entry point. However, the set up I describe aims to create a container that you can use for development and run as a process with all its services.

This container is mounted to your host system, so your existing tools can still be used (such as your text editor).

More importantly, however, this approach skips polluting your host system's setup and does not require rbenv or rvm for Ruby. You also do not need to use Homebrew and install any other dependencies that way. By providing an application container, you can install all your dependencies within a single Dockerfile.

Conclusion

I hope you enjoyed reading about my approach when setting up a new Ruby on Rails project. I've found this style of setup to be lightweight and easy to allow other developers to onboard onto an app, no matter what environment (eg. Mac OS, Linux, or even GitPod.io) they're working on.

In a follow up post to this one, I'll talk about how to modify your set up so that external requests can be routed through a service like ngrok. If you enjoyed this blog post, consider following our blog for more similar content.