Docker helps packaging software into reusable containers. This allows not only for standardized development environment across your team, but also for scaling your production deployments on most major cloud platforms. This post explains how to dockerize a Ruby on Rails app with PostGreSQL, Redis and Sidekiq.

Benefits

Docker has benefits not only for production, but also in development:

  • Tested containers in production
  • Production-like environment across development machines
  • Quick bootstrap of development machines for new team members
  • Version-controlled environment definition via docker-compose
  • Support of major cloud service providers

Dockerize Rails

The first thought you might want to give is, which base image to use. This decision affects the size of the resulting image and the available dependencies.

These are some of our current go-to images, when we create new docker containers for Ruby applications:

  • ruby:<version> - official Ruby image based on Debian with most common Debian packages installed, so that our own Dockerfile does not have to install these.
  • ruby:alpine - official Ruby image based on Alpine Linux which is much smaller than most linux distributions (~5MB). This image does not include any extra packages. We have to install these packages ourselves in our own Dockerfile.

Dockerfile

Create a Dockerfile in the root directory of your Rails application:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
FROM ruby:2.4

# Install dependencies
RUN apt-get update -qq && \
    apt-get install -y --no-install-recommends build-essential libpq-dev nodejs && \
	rm -rf /var/lib/apt/lists/*

# Set the root of your Rails application
ENV RAILS_ROOT /app
RUN mkdir -p $RAILS_ROOT
 
# Set working directory to the root path of the Rails app
WORKDIR $RAILS_ROOT

# Do not install gem documentation
RUN echo 'gem: --no-ri --no-rdoc' > ~/.gemrc

# If we copy the whole app directory, the bundle would install
# everytime an application file changed. Copying the Gemfiles first
# avoids this and installs the bundle only when the Gemfile changed.
COPY Gemfile Gemfile
COPY Gemfile.lock Gemfile.lock
RUN gem install bundler && \
    bundle install --jobs 20 --retry 5

# Now copy the application code to the application directory
COPY . /app

# This scripts runs `rake db:create` and `rake db:migrate` before
# running the command given 
ENTRYPOINT ["lib/support/docker-entrypoint.sh"]

EXPOSE 3000

# Default command is starting the rails server
CMD ["bin/rails", "s", "-b", "0.0.0.0"]

.dockerignore

The .dockerignore file filters some files and folders before starting the build process of the Docker image:

1
2
3
db/*.sqlite3
tmp
log/*

Entry point

The entry point is run for each container. This script makes sure that the database exists and migrations are up to date.

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash

echo "Creating database if it's not present..."
bin/rails db:create

echo "Migrating database..."
bin/rails db:migrate

# If the container has been killed, there may be a stale pid file
# preventing rails from booting up
rm -f tmp/pids/server.pid

exec "$@"

Service Configuration via docker-compose

docker-compose manages all containers needed for your environment. You describe all services and how they interrelate in a docker-compose.yml file and docker-compose takes care of starting and linking them in the correct order.

The definition for a service contains the image, command, environment variables, port mappings, container linkings and volume informations.

For our Rails app we need the following services:

  • PostGreSQL
  • Redis
  • Web Application Server (Rails)
  • Background Worker (Sidekiq)
  • Test Runner (Guard)

The actual definition looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
postgres:
  image: postgres:9.6
  ports:
    - 5432:5432
  volumes:
    - ./tmp/postgresql/9.6/data:/var/lib/postgresql/data
redis:
  image: redis
  ports:
    - 6379:6379
  volumes:
    - ./tmp/redis/data:/data
app:
  build: &build .
  command: rails s -b 0.0.0.0 -p 3000
  tty: true
  volumes: &volumes
    - .:/app
    - ./config/database.yml.dev:/app/config/database.yml
  ports:
    - 3000:3000
  environment: &environment
    DB_USERNAME: postgres
    DB_PASSWORD:
  links: &links
    - postgres
    - redis
    - worker
worker:
  build: *build
  command: bundle exec sidekiq
  volumes: *volumes
  environment: *environment
  links:
    - postgres
    - redis
test:
  build: *build
  command: guard
  tty: true
  volumes: *volumes
  environment:
    <<: *environment
    RAILS_ENV: test
  links:
    - postgres
    - redis

Building the image

Whenever you perform a change to the Gemfile or want to update the container, run this command:

1
docker-compose build app test worker

Development

In development you can now start the whole environment specified in the docker-compose.yml file via one simple command:

1
docker-compose run -p 3000:3000 app

This will start the app container and map the port 3000 to your local port 3000.

You might ask yourself, why I do not use the docker-compose up command. With run we have a better terminal output. In development we might want to use pry to debug certain scenarios or use similar gems. With docker-compose up I had problems with prompts, which were absent with docker-compose run. The only drawback is that we have to specify the port mappings manually for docker-compose run.

Common Commands & Tasks

Common Command With docker-compose
bundle install bundle install; docker-compose build app worker test
rails s docker-compose run -p 3000:3000 app
rake docker-compose run app rake
tail -f log/development.log docker-compose logs app
RAILS_ENV=test rake db:create docker-compose run test rake db:create

Running Tests

We use guard to watch our source files and run the respective tests automatically. When you look into the docker-compose.yml you will find a service called test. This runs the guard server and can be started like this:

1
docker-compose run test

Guard listens for file changes in your project directory, so when you change a source file, the respective test is executed instead of the whole test suite.

Webpacker With Docker-Compose

For our latest Rails 5.1 projects, we have been using webpacker quite successfully. In development you have to start the webpacker-dev-server. Normally this should be accessible by the Rails development server but also from outside to load the assets from.

For that we had to adjust the hostname for the rails server in config/webpacker.yml:

1
2
3
4
5
6
7
8
9
10
11
# ...
development:
  <<: *default
  compile: true

  dev_server:
    host: webpacker
    port: 3035
    hmr: true
    https: false
# ...

And add a linked service to the docker-compose.yml:

1
2
3
4
5
6
7
8
9
webpacker:
  build: *build
  command: bin/webpack-dev-server --host localhost
  entrypoint: ""
  ports:
    - 3035:3035
  volumes: *volumes
  environment:
    RAILS_ENV: development

The --host localhost option makes sure, that the assets are loaded from localhost and the hot-module refresh connects to the correct host (in that case the forwarded port 3035 on localhost).

Remember to add the webpacker service to app → links definitions so that the webpacker-dev-server is startet automatically with docker-compose run -p 3000:3000 app.

Deployment Possibilities

Normally we integrate the deployment in our continuous integration system. Whenever we push to the main repository a job builds the image, runs all tests and when everything is green it pushes the image to the repository. Depending on the branch and tag for the commit, it then gets deployed to the respective environment.

You could deploy to a variety of servers and cloud platforms:

Single Host - docker-compose

The simplest scenario would be to deploy to a dedicated server which has Docker and docker-compose installed.

In this scenario you have to create a docker-compose.yml file, for instance at /srv/docker-compose.yml, adjust some port mappings and maybe add an nginx reverse proxy.

Using the restart: always directive, docker-compose would take care of restarts for your services.

Further reading: docs.docker.com

Dokku

Dokku is a lightweight PaaS solution. You can install it on a server and manage deployments with Dokku.

Further reading: dokku.viewdocs.io

Amazon AWS Elastic Beanstalk

Amazon Web Services provide a large toolbelt to host, scale and manage cloud infrastructure at Amazon data centers. Elastic Beanstalk is our favourite tool to quickly deploy docker environments to AWS EC2 instances.

It provides a simple command-line interface (awsebcli) on top of the basic AWS CLI tool (awscli). Everything you could do with the AWS Management Console, you can do with the command-line interface as well.

We have written a blog post “Rails in Docker via AWS Elastic Beanstalk”, which outlines how you can deploy a dockerized Rails app to AWS with auto-scaling and load-balancing in a few minutes.

Further reading: aws.amazon.com

Heroku

Heroku is a Cloud Service Provider with very easy command-line utilities to quickly deploy applications to production environments.

Further reading: heroku.com

Conclusion

Docker helps packaging applications in easily deployable containers not only for production, but for development environments as well.

I hope this article could help you to gain a good understanding of the benefits and some of the pitfalls of Rails inside Docker and that you can evaluate whether Docker is something that’s worth to look into for your project too.