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.
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:
- PostgreSQL
- Redis
- Chrome Server (for running system tests)
- 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.