Photo by Ian Taylor on Unsplash
Simplify Rails App Deployment Using Docker
Optimize Rails deployment using Docker to manage multiple Ruby versions, eliminate setup issues, and boost development efficiency
I can honestly say that I have given up on managing multiple Ruby and Rails versions on my computer. Regardless of whether it's homebrew, asdf, rbenv, or any other new package manager, I've had enough. This applies to open-source projects, take-home assessments for interviews, and even pair-programming sessions. All Rails apps/projects should be Dockerized, and if they aren't, rest assured that a pull request is on its way to set it up accordingly by yours truly. In fact, in the upcoming Rails 7.1 release, by default a Dockerfile will be included.
Fresh Setup
By "Fresh", I mean just having Docker Desktop and a Text Editor - No Rails necessary.
Confirm installation of Docker - (Community Edition will work fine) - with
docker -v
docker -v # Docker version 23.0.5, build bc4487a
Set up a docker container with a ruby image in which to create the rails app.
docker run --rm -it -v "$PWD":/usr/src/app -w /usr/src/app ruby:3.2.2-bullseye bash -c "gem install rails && rails new . -d postgresql -T"
This command runs a Docker container using the
ruby:3.2.2-bullseye
image, with several options:docker run
: Run a Docker container.--rm
: Automatically remove the container when it exits.-it
: Allocate a tty for interactive input and output.-v "$PWD":/usr/src/app
: Mount the current directory as a volume inside the container at the path/usr/src/app
.-w /usr/src/app
: Set the working directory inside the container to/usr/src/app
.ruby:3.2.2-bullseye
: Use theruby:3.2.2-bullseye
image as the base image for the container./bin/bash
: Start an interactive shell inside the container.-c "gem install rails && rails new . -d postgresql -T"
: Execute a shell command inside the container that installs Rails usinggem install
and then runs therails new
command within the current directory with the specified options - setting the database to PostgreSQL and optionally skipping tests setup.We should now have a Rails app generated on our machine. Next, we want to dockerize it so that we can run all processes, such as the server, console, migrations, and more within containers. Let's begin with the server; to do this, create a Dockerfile (also written as dockerfile, which is my preference).
FROM ruby:3.2.2-bullseye # Set the working directory inside the container WORKDIR /app # Update Dependencies RUN apt-get update && \ apt-get clean # Copy the Gemfile and Gemfile.lock from the host into the container COPY Gemfile Gemfile.lock ./ # Install the RubyGems RUN gem install bundler:2.4.13 && \ bundle config --global frozen 1 && \ bundle install --jobs 4 --retry 3 # Copy the rest of the application into the container COPY . . # Expose port 3000 EXPOSE 3000 # Start the Rails server CMD ["rails", "server", "-b", "0.0.0.0"]
Now, we need a container that includes a PostgreSQL image and connects it to the Rails app on the same network. An easy approach is to use a docker-compose.yaml file (or compose.yaml, depending on your preference).
services: db: container_name: db image: postgres:14-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: attic_development volumes: - db_date:/var/lib/postgresql/data web: container_name: web build: . ports: - "3000:3000" depends_on: - db environment: DATABASE_URL: postgresql://postgres:postgres@db/attic_development volumes: - .:/app - gem_cache:/usr/local/bundle/gems volumes: gem_cache: db_data:
The
compose.yaml
file is used to define a multi-container Docker application. It consists of a version number (in this case, version 3), and a list of services that make up the application.The
services
section of the file contains two services:db
: This service is defined using the official PostgreSQL Docker image tagged14-alpine
. It sets three environment variables:POSTGRES_USER
: The username for the default PostgreSQL user.POSTGRES_PASSWORD
: The password for the default PostgreSQL user.POSTGRES_DB
: The name of the default database to be created.
web
: This service is defined to build a Docker image from the dockerfile
in the current directory (.
). It maps port 3000
of the host to port 3000
of the container. It depends on the db
service, which means that the db
service will be started before the web
service. It also sets the DATABASE_URL
environment variable to connect the Rails app to the PostgreSQL database. The depends_on
section specifies that the db
service should be started before the web
service. The environment
section sets the DATABASE_URL
environment variable to the URL of the PostgreSQL database. The format of the URL is postgresql://<username>:<password>@<hostname>/<database>
. In this case, the username is postgres
, the password is postgres
, the hostname is db
(the name of the db
service), and the database name is attic_development
.
Now we can run both services together:
docker compose up -d
The "-d" option is for detached mode, which means running in the background. In most cases, this is the ideal mode, as without this flag, our terminal will stream the Rails server logs. This would require us to open a new terminal tab to run other commands for migrations, interact with the console, and so on.
Common Commands:
Running various Rails commands within the web container:
docker compose run web rails c docker compose run web rails g model|controller|migration ... docker compose run web rails db:migrate docker compose run web rails t docker compose run web bundle exec rspec docker compose run web bundle add|install|update|remove [gem]
The above
docker compose run web rails
should be saved into an alias such as:alias dcr="docker compose run web rails" alias dcb="docker compuse bundle" alias dcbr="docker compose bundle exec rspec"
View
rails s
logs:docker logs -f web
Best Practices
The above is a minimal dockerized rails app sufficient for development. However, there are additional changes we would need to make to adhere to some best practices and make it ready for deployment in a production environment. For instance, we could use a slimmer image of Ruby, create and use a non-root user in our container, set up multi-stage builds, etc. There's a lot to glean from the examples in these posts for better Dockerfile and Docker Compose files. Additionally, Docker has an official post on this matter.
Setup for Optimal Development/Production
This is most likely the most difficult part as there will likely be variances in how lean and performant one's production docker setup is in comparison to others found online. Furthermore, this comes with experience and testing. As in most cases where I am my own DevOps team, it's better to lean on the expertise of others who readily provide it. There are numerous rails-centric templates with fully-configured docker setup with GitHub actions for automated deployments on GitHub.
I came across Ruby on Whales post and it was truly a godsend. They clearly outline and explain their decisions for their particular setup and keep the post updated. Furthermore, they provide a template so anyone can duplicate their configurations. The script run in their generator can be found here. The setup is quite straightforward by running the below inside our rails app directory:
gem install rbytes
gem install dip
rbytes install https://railsbytes.com/script/z5OsoB
dip provision
The rbytes command will install an interactive script to set up the docker environment in a .dockerdev folder and include a dip.yml
file. The script itself can be found here for reference and forked for modification. For a full scope of the inner workings of the generator reference the Ruby on Whales Github repo.
Dip Dip (Docker Interaction Program) is a tool to simplify the previously complicated method of utilizing Docker Compose, making the process smoother. Review the dip.yml
to see the commands we can run:
version: '7.1'
# Define default environment variables to pass
# to Docker Compose
environment:
RAILS_ENV: development
compose:
files:
- .dockerdev/compose.yml
project_name: attic
interaction:
# This command spins up a Rails container with the required dependencies (such as databases),
# and opens a terminal within it.
runner:
description: Open a Bash shell within a Rails container (with dependencies up)
service: rails
command: /bin/bash
# Run a Rails container without any dependent services (useful for non-Rails scripts)
bash:
description: Run an arbitrary script within a container (or open a shell without deps)
service: rails
command: /bin/bash
compose_run_options: [ no-deps ]
# A shortcut to run Bundler commands
bundle:
description: Run Bundler commands
service: rails
command: bundle
compose_run_options: [ no-deps ]
rails:
description: Run Rails commands
service: rails
command: bundle exec rails
subcommands:
s:
description: Run Rails server at http://localhost:3000
service: web
compose:
run_options: [ service-ports, use-aliases ]
test:
description: Run unit tests
service: rails
command: bundle exec rails test
environment:
RAILS_ENV: test
yarn:
description: Run Yarn commands
service: rails
command: yarn
compose_run_options: [ no-deps ]
psql:
description: Run Postgres psql console
service: postgres
default_args: attic_development
command: psql -h postgres -U postgres
provision:
# We need the `|| true` part because some docker-compose versions
# cannot down a non-existent container without an error,
# see https://github.com/docker/compose/issues/9426
- dip compose down --volumes || true
- dip compose up -d postgres
- dip bash -c bin/setup
The first command to run would be dip provision
which as shown above is for a fresh setup - shutting down volumes, starting postgres service, and running the bin/setup
script which will install/update gems, prepare the database, and restart the rails:
#!/usr/bin/env ruby
require "fileutils"
# path to our application root.
APP_ROOT = File.expand_path("..", __dir__)
def system!(*args)
system(*args) || abort("\n== Command #{args} failed ==")
end
FileUtils.chdir APP_ROOT do
# This script is a way to set up or update our development environment automatically.
# This script is idempotent, so that we can run it at any time and get an expectable outcome.
# Add necessary setup steps to this file.
puts "== Installing dependencies =="
system! "gem install bundler --conservative"
system("bundle check") || system!("bundle install")
# puts "\n== Copying sample files =="
# unless File.exist?("config/database.yml")
# FileUtils.cp "config/database.yml.sample", "config/database.yml"
# end
puts "\n== Preparing database =="
system! "bin/rails db:prepare"
puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"
puts "\n== Restarting application server =="
system! "bin/rails restart"
end
We can run rails-specific commands defined in the dip.yml
just like we did previously but prefixed with dip
, but a more extensive list of available commands are written in the .dockerdev/README.md
file:
# snippet from .dockerdev/README.md #
# run rails server
dip rails s
# run rails console
dip rails c
# run rails server with debugging capabilities (i.e., `debugger` would work)
dip rails s
# or run the while web app (with all the dependencies)
dip up web
# run migrations
dip rails db:migrate
# pass env variables into application
dip VERSION=20100905201547 rails db:migrate:down
# simply launch bash within app directory (with dependencies up)
dip runner
# execute an arbitrary command via Bash
dip bash -c 'ls -al tmp/cache'
# Additional commands
# update gems or packages
dip bundle install
dip yarn install
# run psql console
dip psql
# run tests
# TIP: `dip rails test` is already auto prefixed with `RAILS_ENV=test`
dip rails test
# shutdown all containers
dip down
From the snippet above we can run the same rails-centric commands prefixed with dip
, however, there is also an option that makes the prefix unnecessary depending on your shell.
Final Thoughts
I have found the interactive CLI option to be my go-to approach as it adheres to best practices and doubly gives the options to set the Rails and PostgreSQL versions (among other environment variables) which I usually hardcode. Currently working on various self and OSS projects with differing rails versions and I am at peace with integrating them with docker and contributing without the hassle of wrangling with the setup of multiple rails versions and gems issues based on my OS.