Hi there Ruby developers! If you’ve been looking for an effective way to establish a Ruby on Rails Docker setup for your local development environment, then this post is for you. It’s a continuation of our previous article on how to install Ruby in a macOS for local development.
Ruby developers frequently need to account for a database when building a Ruby on Rails project, as well as other development environment prerequisites. However, ensuring all of these are installed on your host operating system — and use the exact required versions — might sometimes be a challenge.
Every time I was attempting to create a new project, I found myself wishing for a button that created a new, clean environment for it. So, I decided to create a Docker-powered setup that can easily be started and stopped using Docker’s docker-compose.yml
.
What is Docker Compose?
Docker Compose is an Infrastructure as Code (IaC) tool that allows you to configure and connect applications and services such as proxies, databases, and volume mounts in order to provision them in a reusable way. It’s installed as part of the Docker software bundle, and defined using YAML.
This Docker Compose setup for a local development environment can be used for different types of projects, and isn’t solely applied to Ruby on Rails. It could also be used in side-projects or frontend client-side projects. The main reasons I prefer to use Docker environments instead of changing my own host operating system include:
- easy to configure
- easy to split logic from the environment
- easy to use the same env for different projects
- easy to share the environment without any sensitive code to anyone who needs help with it
- host machine stays clean
A Ruby on Rails Docker setup
In order to setup a Ruby on Rails Docker environment for local development, you’ll need the following:
- Unix like OS (I use a macOS, but you can use Ubuntu or any other Linux distro. Windows should also work)
- Git
- make (3.81 or later)
- Docker (19.03.12 or later)
- docker-compose (1.26.0 or later)
- Your preferred text editor (Visual Studio Code in my case)
Once you have those, you’re ready to get started. For those who don’t want to read – here is the open source repository with all of the required instructions and the full source code to all of the snippets that we’ll review. B since you’re here already, simply follow this article for a step-by-step guide.
To start off I’ll create a Ruby on Rails project named foo_bar_project
for the sake of simplicity.
1) Get the Docker Compose environment configuration and remove the git repository. If you want to keep everything tracked through a git source-code repository, you can create your own later.
git clone git@github.com:mtereschenko/simple_ror_environment.git && cd simple_ror_environment && rm -rf .git
Next steps should be executed in the ./environment
directory, so let’s make sure you are running commands inside it:
cd ./environment
2) Now, you need to assign a project name to this environment setup. By default, it’s test_project
. In our code walkthrough here, we need to update it to match foo_bar_project
as we’ve previously described. To do so, make the changes in the ./environment/.env.example
file to reflect it in the PROJECT_NAME
variable. It’s important to use lower case Snake Case as values to variables:
PROJECT_NAME=foo_bar_project
3) Next you are ready to create your project by running the following command:
make init
As you can see, you now have a new folder called application
with Ruby On Rails code inside.
4) Now you need to build your local development environment with Docker Compose. This step is obligatory after any changes in the Dockerfile
or Gemfile
of your application.
make build
5) This allows you now to spin up the Ruby local development environment as follows:
make start
Notice that this isn’t yet starting a Ruby on Rails application. To do that, we need to open a shell to the Docker environment and instruct it to start the rails
process. We’ll do that as follows:
make shell
rails s -p 3000 –binding=0.0.0.0
Finally, if you open your browser and navigate to this url, http://foo_bar_project.localhost/
, you should see the Ruby on Rails project that we’ve just built.
So, what’s available for us now?
- We have a project with an environment directory that contains all we need to run our project, and an application directory which contains our application code. This directory is mapped into the Docker container, so you have full access from the Ruby Docker image.
- We have quick access to the Ruby shell, so we do not need to have a Ruby environment installed locally, such as Rails, Rake, Bundler, or any other Ruby toolchain.
- Commands can be executed after the
make shell
command. - This Docker Compose setup of the project has two preconfigured stages, so if you ever need to deploy your Ruby application somewhere, you can create your own stage. It’s as simple as that!
If you’re keen on how the above Docker environment for Ruby on Rails works, we’re going to break it down into the Ruby Docker image, the Makefile, and the Docker Compose definition that makes all of it tick together.
A Ruby Docker image
First off, the Ruby Docker image is an essential part, and building it correctly is also important to ensure that we have an effective re-use of Docker image layers cache, and other concerns relating to a local development environment.
Following is the Dockerfile
for the Ruby Docker image:
FROM ruby:2.7.6-alpine3.16 as base_image
RUN apk add --no-cache git \
build-base \
libpq-dev \
tzdata
FROM base_image as development
COPY ./artifacts/rails/Gemfile /tmp/Gemfile
COPY ./artifacts/rails/Gemfile.lock /tmp/Gemfile.lock
COPY ./containers/ruby/runners/runner.development.sh /rdebug_ide/runner.sh
RUN cd /tmp && \
gem install ruby-debug-ide && \
gem install debase && \
bundle install && \
chmod +x /rdebug_ide/runner.sh && \
apk add --no-cache git \
nodejs \
yarn
WORKDIR /app
ENTRYPOINT ["tail", "-f", "/dev/null"]
FROM base_image as init
COPY ./containers/ruby/initializers/runner.init.sh /tmp/runner.sh
COPY ./containers/ruby/initializers/database.yml /tmp/database.yml
COPY ./containers/ruby/initializers/.gitignore /tmp/.gitignore
COPY ./containers/ruby/initializers/development.rb /tmp/development.rb
RUN chmod +x /tmp/runner.sh
WORKDIR /app
ENTRYPOINT ["/tmp/runner.sh"]
As you can see, it makes some assumptions on peripheral configuration that is needed to have a functional Ruby on Rails application environment, such as:
-
database.yml
file that is seeded for the Ruby on Rails database connection details -
development.rb
for Ruby on Rails runtime configuration for the development environment
It does so using the runners.sh
script in the ./environment/containers/ruby/initializers
directory:
#!/bin/ash
cd /app
gem install rails
rails new . -d=postgresql --skip-git
yes | cp -rf /tmp/database.yml /app/config/database.yml
yes | cp -rf /tmp/development.rb /app/config/environments/development.rb
cp /tmp/.gitignore /app/.gitignore
This Dockerfile also makes use of Multistage Docker, so that specific parts can be effectively reused throughout different environments if you wish to use the same setup for different workflows (testing, staging, and so on).
A Rails Docker Compose
The Docker Compose file in ./environment/docker-compose.development.yml
helps glue all the services together for a functional Ruby on Rails application on Docker:
- An nginx HTTP server
- A Ruby application
- A PostgreSQL database server
The following is the Rails Docker Compose file in use by this setup:
version: '3.7'
services:
nginx:
image: "${PROJECT_NAME}/nginx:development"
container_name: ${PROJECT_NAME}-nginx
build:
context: ./
dockerfile: ./containers/nginx/Dockerfile
depends_on:
- ruby
tty: true
ports:
- 80:80
volumes:
- ./`artifacts`/nginx/:/`var`/log/nginx:cached
ruby:
image: "${PROJECT_NAME}/ruby:development"
container_name: ${PROJECT_NAME}-ruby
depends_on:
- postgres
build:
context: ./
dockerfile: ./containers/ruby/Dockerfile
target: development
ports:
- 13030:13030
volumes:
- ${APP_PATH}:/app:cached
environment:
DB_NAME: ${DB_NAME}
PROJECT_NAME: ${PROJECT_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_PORT: ${DB_PORT}
PUMA_WORKERS: 0
RAILS_MAX_THREADS: 1
postgres:
image: "${PROJECT_NAME}/postgres:development"
container_name: ${PROJECT_NAME}-postgres
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
build:
context: ./
dockerfile: ./containers/postgres/Dockerfile
ports:
- ${DB_PORT}:5432
volumes:
- postgres_volume:/var/lib/postgresql/data
volumes:
postgres_volume:
Makefile for Docker
Finally, in order to create an accessible interface for developers to easily interact with this local development environment for Ruby, a Makefile is used:
.PHONY: help
# Make stuff
-include .env
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
.DEFAULT_GOAL := help
ARTIFACTS_DIRECTORY := "./artifacts"
CURRENT_PATH :=${abspath .}
SHELL_CONTAINER_NAME := $(if $(c),$(c),ruby)
BUILD_TARGET := $(if $(t),$(t),development)
help: ## Help.
@grep -E '^[a-zA-Z-]+:.*?## .*$$' Makefile | awk 'BEGIN {FS = ":.*?## "}; {printf "[32m%-27s[0m %s\n", $$1, $$2}'
init: ## Project installation.
@rm -f ./.env
@cp .env.example .env
@make init_app_directory
@make create_postgress_volume
@docker-compose -f docker-compose.init.yml build
@docker-compose -f docker-compose.init.yml up
build: ## Build images.
@make create_project_artifacts
@cp ${APP_PATH}/Gemfile "${ARTIFACTS_DIRECTORY}/rails/Gemfile"
@cp ${APP_PATH}/Gemfile.lock "${ARTIFACTS_DIRECTORY}/rails/Gemfile.lock"
@docker-compose -f docker-compose.$(BUILD_TARGET).yml build
shell: ## Internal image bash command line.
@if [[-z `docker ps | grep ${SHELL_CONTAINER_NAME}`]]; then \
echo "${SHELL_CONTAINER_NAME} is NOT running (make start)."; \
else \
docker-compose -f docker-compose.$(BUILD_TARGET).yml exec $(SHELL_CONTAINER_NAME) /bin/ash; \
fi
start: ## Start previously builded application images.
@make create_project_artifacts
@make start_postgres
@make start_ruby
@make start_nginx
run: ## Run ruby debugger session.
@docker-compose -f docker-compose.$(BUILD_TARGET).yml exec ruby /bin/ash /rdebug_ide/runner.sh
start_ruby: ## Start ruby image.
@if [[-z `docker ps | grep ruby`]]; then \
docker-compose -f docker-compose.$(BUILD_TARGET).yml up -d ruby; \
else \
echo "Ruby is running."; \
fi
start_postgres: ## Start postgres image.
@if [[-z `docker ps | grep postgres`]]; then \
docker-compose -f docker-compose.$(BUILD_TARGET).yml up -d postgres; \
else \
echo "Postgres is running."; \
fi
start_nginx: ## Start nginx image.
@if [[-z `docker ps | grep nginx`]]; then \
docker-compose -f docker-compose.$(BUILD_TARGET).yml up -d nginx; \
else \
echo "Nginx is running."; \
fi
stop: ## Stop all images.
@docker-compose -f docker-compose.$(BUILD_TARGET).yml stop
create_project_artifacts:
mkdir -p ./artifacts/rails
mkdir -p ./artifacts/db
init_app_directory:
@mkdir -p ${APP_PATH}
create_postgress_volume:
@sed -i '' -r "s/postgres_volume:/${PROJECT_NAME}_db_volume:/g" docker-compose.development.yml
Running Ruby applications efficiently
Hopefully you’ve now earned a new skill of running Ruby applications, such as Ruby on Rails, on your local environment, via the use of Docker containers configuration. Another way of running local Ruby applications is through the use of Ruby virtual environments, which we covered previously in our post on how to install Ruby on macOS.
Now that you’ve got your Ruby development environment all worked out, you might also want to learn how to secure your Ruby applications. Check out the following articles for more information on Ruby security:
Secure your Ruby applications for free
Create a Snyk account today to find and fix vulnerabilities in your Ruby containers.