Using Docker for Rails Development

Why

There are many use cases of Docker. I see people primarily using it for Continous Integration and deployment. But Docker is also good for development. The obvious advantages of using Docker for development are:

  • No need to install app dependencies on dev machines. App dependencies are built into Docker images. Hence, the dev machines are not messed up with crazy dependencies. The only dependency needed on dev machines is Docker, nothing else.
  • Have a consistent development environment for all developers. No more excuse like "It works on my machine"!
  • Onboard new developers quickly. No need to spend hours setting up new dev machine and configuring it. You only need docker-compose up and you can start coding.

Prerequisites

This post will show you how to setup a Ruby on Rails development environment using Docker. My dev machine has

  • OS X EI Capitan
  • Docker version 1.10.1
  • docker-compose version 1.6.0

Please note my Docker and docker-compose versions. If yours have lower versions, the example here will NOT work for you. In particular, I'm using docker-compose file Version 2, which are supported by Compose 1.6.0+ and require a Docker Engine of version 1.10.0+.

Architecture

The architecture of the app looks like this

We have two containers, one for app and one for db. The web container has Nginx, Passenger and the Rails app. The db container only has Postgres in it. This architecture is probably common for many Rails apps running in production. And as a good practice, you should make your development environment as close to production as possible so that the potential production issues are exposed in the development stage, not when you receive calls from unhappy customers.

Build and Run Container Web

Step 1. Create Rails Project

My dev machine only has Docker installed. It doesn't have ruby. How do I create a Rails project? Use Docker.

docker run -it --rm --user "$(id -u):$(id -g)" -v "$PWD":/apps -w /apps rails:4.2.5 rails new rails_on_docker_for_dev --skip-bundle

The above command (btw, it's one line) will pull the image rails:4.2.5 from Docker Hub and run a one-off container based on the image with the command rails new rails_on_docker_for_dev --skip-bundle. The result is that it creates a Rails 4.2.5 project called rails_on_docker_for_dev in your current directory. Take a look at the newly created project rails_on_docker_for_dev.

Step 2. Dockerize the app

Create Dockerfile in the project

FROM phusion/passenger-ruby22:0.9.18
MAINTAINER Cheng Long <me@chengl.com>

# Hack to get around the volume mount issue on Mac
# https://github.com/boot2docker/boot2docker/issues/581
# https://github.com/docker/docker/issues/7198#issuecomment-159736577
ARG LOCAL_USER_ID
RUN usermod -u ${LOCAL_USER_ID} app

ENV APP_NAME rails_on_docker_for_dev

# Enable Nginx and add config
RUN rm -f /etc/service/nginx/down
RUN rm /etc/nginx/sites-enabled/default
COPY $APP_NAME.conf /etc/nginx/sites-enabled/$APP_NAME.conf

# Create project root and change owner
ENV APP_PATH /home/app/$APP_NAME
RUN mkdir -p $APP_PATH
RUN chown -R app:app $APP_PATH

WORKDIR $APP_PATH

RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Use baseimage-docker's init process.
CMD ["/sbin/my_init"]

This is a standard Dockerfile. It's based on phusion/passenger-ruby22:0.9.18, which includes Nginx 1.8.0, Passenger 5.0.22 and Ruby 2.2. It basically has all the dependencies to run the container web. You probably notice that the image doesn't include the Rails app. That's because we want to mount the Rails app as a volume so that when we edit source code of the app we don't have to rebuild the image. This is much needed in development mode to shorten the feedback loop. But in production, you should include the app in the image. We will see how it's mounted in docker-compose.yml

Two lines in the Dockerfile need a bit explanation

ARG LOCAL_USER_ID
RUN usermod -u ${LOCAL_USER_ID} app

First, Passenger recommends running the app as user app because it's a good security practice. So user app needs rw access to the mounted volume.

Second, in order to let app have rw access and not to change the ownership of the files (because we want to edit the source code on local machine and the files are owned by the local user), we change app's uid to be the same as the local user. This is a trick to get around the permission issue of mounted volume on OS X.

Create rails_on_docker_for_dev.conf

server {
    listen 80;
    server_name 0.0.0.0;
    root /home/app/rails_on_docker_for_dev/public;

    passenger_enabled on;
    passenger_user app;
    passenger_ruby /usr/bin/ruby;
    passenger_app_env development;
}

Note passenger_user app;, the app is running as app not root.

Create docker-compose.yml

version: '2'
services:
  web:
    build:
      context: .
      args:
        - LOCAL_USER_ID=${LOCAL_USER_ID}
    ports:
      - "80:80"
    volumes:
      - ".:/home/app/rails_on_docker_for_dev"

Note the volumes part. We mount the project directory in the local dev machine as a volume to /home/app/rails_on_docker_for_dev inside the container so that the container has the source code and Rails auto-reloading still works.

Step 3. Build and Run

Build the image

LOCAL_USER_ID=$(id -u) docker-compose build

We need LOCAL_USER_ID=$(id -u) because docker-compose.yml expects an environment varialbe LOCAL_USER_ID so that it can change user app's uid to the local user's uid.

Run the image

docker-compose up

You will see some logs that Passenger prints out in the console. When it's ready, point your broswer to your docker machine ip. To find out your docker machine ip, docker-machine ip default, if you are using machine default.

You should see an error that Passenger complains about bundle install is not run yet. That's correct. The app doesn't have any gem yet.

First Bundle install

docker-compose run --rm --user $(id -u):$(id -g) web bundle install --path=vendor/bundle

We want to install gems in ./vendor/bundle because the gems will persist in ./vendor/bundle regardless of the lifecyle of the container. When we update the Gemfile and do bundle install again, it will only install the newly added gems, not everything again.

--user $(id -u):$(id -g) is to make sure that newly created files are owned by the local user. Without it, they will be owned by root.

--rm is to remove the container after it's done.

Remove the running container

docker rm -f $(docker ps -ql)

Run the image again

docker-compose up

If you refresh your broswer, you should see a familiar welcome page!

Step 4. Develop using Docker

Having only a welcome page is not very exciting. Let's add a Post model.

docker-compose run --rm --user $(id -u):$(id -g) web bundle exec rails g scaffold post title:string body:text

DB migrate

docker-compose run --rm --user "$(id -u):$(id -g)" web bundle exec rake db:migrate

Now you can CRUD posts. Everything works!

Build and Run Container DB

Step 5. Replace sqlite3 with pg

Replace sqlite3 with pg in Gemfile

gem 'pg', '~> 0.18.4'

bundle install

docker-compose run --rm --user $(id -u):$(id -g) web bundle install --path=vendor/bundle
Step 6. Update Web Image

Add Postgres dependency in Dockerfile

RUN apt-get update && apt-get install -qq -y libpq-dev --fix-missing --no-install-recommends

Update docker-compose.yml so that web and db are linked

version: '2'
services:
  web:
    build:
      context: .
      args:
        - LOCAL_USER_ID=${LOCAL_USER_ID}
    ports:
      - "80:80"
    volumes:
      - ".:/home/app/rails_on_docker_for_dev"
    links:
      - db

  db:
    image: postgres:9.4.5
    ports:
      - '5432:5432'

Update database.yml to use Postgres

development: &default
  adapter: postgresql
  encoding: utf8
  database: myapp_dev
  pool: 5
  username: postgres
  host: db

test:
  <<: *default
  database: myapp_test
Step 7. Rebuild images and Run
LOCAL_USER_ID=$(id -u) docker-compose build

Run

docker-compose up

You should see logs from both web and db printed in the console.

Of course, you should setup database first

docker-compose run --rm --user "$(id -u):$(id -g)" web bundle exec rake db:setup

Everything works as before!

All source code is here.

Summary

As you see, it's not that hard to use Docker for Rails development. Essentially, we just need Dockerfile anddocker-compose.yml and link the containers properly. The development workflow with Docker is pretty much the same as without Docker. But the advantages that Docker brings to development are invaluable. It abstracts away the difference between local dev machines and let every developer in your team have a consistent development environment. Isn't that awesome?

Happy Dockering!