Setting Up A Rails Development Environment Using Docker11 Sep 2016
A couple of years ago I wrote about how to setup a local development environment using Vagrant. While Vagrant has worked out great, containerization with Docker has become all the rage. So I decided to take it for a spin to see what all the fuss is all about. I’m going to use an existing Rails app as my test subject.
So before we get started, here’s what you’ll need to install:
Here’s an overview of the folder structure we’ll be creating. We’ll store our bundle, Postgres data and the web app itself on our host. We’ll also symlink the keys folder to our SSH keys:
ln -s ~/.ssh/ keys
I’ll get more into the advantages of doing so further along in this post.
myapp-dev-box/ ├── Dockerfile ├── docker-compose.yml ├── docker-sync.yml ├── myapp/ ├── bundle/ ├── pgdata/ └── keys/ -> ~/.ssh/
First thing we’ll do is create a
Dockerfile which will be our starting point inside an empty dir called
myapp-dev-box. Since this is for a Rails project, we’ll base this container off the official Ruby image on Docker Hub which you can think of as GitHub for containers.
Next we’ll install some dependencies that we need for the web app. Notice we will run this as a single command. Reason being, Docker caches the state of the container after each command into an intermediate container to speed things up. You can read a bit more about how this works here.
So if we need to add or remove a package, it’s recommended to re-run the entire step including
apt-get update to make sure the entire dependency tree is updated.
RUN apt-get update && apt-get install -y \ build-essential \ wget \ git-core \ libxml2 \ libxml2-dev \ libxslt1-dev \ nodejs \ imagemagick \ libmagickcore-dev \ libmagickwand-dev \ libpq-dev \ ffmpegthumbnailer \ && rm -rf /var/lib/apt/lists/*
Now let’s create a folder for our app and a folder to store the SSH keys. The SSH keys are needed to checkout private repositories as part of the
bundle install step inside the container.
Alternatively, you can make use of a build flow tool like Habitus to securely share a common set of keys and destroy them later in the build process. You can read more about it here. It supports many different complex build flows making it ideal for production use. Although it adds more complexity than we need just for a development environment so I’ve dediced against using it here.
You can also always create a separate set of SSH keys (without password) and place them in the same folder as the
Dockerfile to be used within the container. Although this approach is a lot less secure as those keys would essentially become part of the container cache and could be exploited if someone gets hold of the image history. I wouldn’t recommend it.
Feel free to skip this step altogether if your
Gemfile doesn’t reference any private repositories.
We’ll also add Github and BitBucket domains to the known hosts file to avoid first connection host confirmation during the build process.
RUN mkdir /myapp RUN mkdir -p /root/.ssh/ WORKDIR /myapp RUN ssh-keyscan -H github.com >> ~/.ssh/known_hosts RUN ssh-keyscan -H bitbucket.org >> ~/.ssh/known_hosts
Generally, we would copy
Gemfile.* and run
bundle install as a separate step into our Dockerfile so it can be run once and cached during subsequent runs.
Although that has some downsides especially for a development environment. First and foremost, the bundle would have to be rebuilt from scratch every time the
Gemfile is changed which could get frustrating if it’s changed frequently.
Since I was primarily focused on a development environment and wanted to make this setup process as frictionless as possible for new devs, I decided to set it up in a way where the state of our bundle can be persisted after running it once even after we shut down the container and start it back up, just like it would on a VM or a local machine. And it would utilize the same SSH keys that are already present on the developer’s machine.
To make that happen, we will go ahead and point the
GEM_HOME to a root folder called
bundle which will be synced from the host. We’ll also update the bundle configuration to point to that path.
ENV GEM_HOME /bundle ENV PATH $GEM_HOME/bin:$PATH RUN gem install mailcatcher RUN gem install bundler -v '1.10.6' \ && bundle config --global path "$GEM_HOME" \ && bundle config --global bin "$GEM_HOME/bin"
Now that we have setup our base app container, it’s time to build and link a couple of supporting containers to run our app. Docker Toolbox includes a great tool called docker-compose (previously known as fig) to help us do just that.
Let’s start by defining which services we want to run, we’ll split our app into a
job services. For
redis services, we’ll use the official Postgres and Redis images provided by Docker Hub without any custom changes. For
web services, we’ll instruct it to build the image from the
Dockerfile which we just created in the current directory in the previous section.
version: '2' services: db: image: postgres volumes: - ./pgdata:/pgdata environment: POSTGRES_DB: myapp_development POSTGRES_USER: postgres POSTGRES_PASSWORD: password4postgres PGDATA: /pgdata redis: image: redis web: build: . command: bundle exec rails server --port 3000 --binding 0.0.0.0 volumes_from: - container:myapp-web-sync:rw - container:myapp-bundle-sync:rw volumes: - ./keys:/root/.ssh/ ports: - "3000:3000" environment: REDIS_URL: redis://redis:6379 links: - db - redis job: build: . command: bundle exec sidekiq volumes_from: - container:myapp-web-sync:rw - container:myapp-bundle-sync:rw volumes: - ./keys:/root/.ssh/ ports: - "6379:6379" environment: REDIS_URL: redis://redis:6379 links: - db - redis volumes: myapp-web-sync: external: true myapp-bundle-sync: external: true
Most of the configuration under each service instance is pretty self-explanatory but there are a couple of interesting things to note. First, under the
db instance, you’ll notice we have a
links section which tells docker compose that these two services depend on the
redis services. So docker compose will be sure to start the linked services before it starts
Second, you’ll notice we have a
volumes is the native docker syntax for mounting a directory from the host as a data volume. While this is super useful, it tends to be very slow! So for now, we are mostly going to limit using it for sharing the database and the SSH keys. For things that are most disk I/O intensive, we’ll define external volumes in
volumes_from section which will utilize a gem by Eugen Mayer called docker-sync. It will give us the ability to use rsync or unison which should significantly boost performance.
For that, we’ll need to define yet another configuration file in which we will define what
myapp-bundle-sync volumes will do. As their name suggest, we’ll use each of them to sync our web project files and the bundle respectively. Note, each sync project will have to have it’s own unique port.
syncs: myapp-web-sync: src: './myapp' dest: '/myapp' sync_host_ip: 'localhost' sync_host_port: 10872 myapp-bundle-sync: src: './bundle' dest: '/bundle' sync_host_ip: 'localhost' sync_host_port: 10873
Assuming you have your web project cloned into
myapp-dev-box/myapp let’s go through the following steps:
Install the bundle
host $ docker-compose run web bundle install
Note we will only have to do this once, since we have the bundle state shared between our
job services using a docker-sync volume. We only need to re-run this if/when the
Run the migrations
host $ docker-compose run web rake db:migrate
Again like the previous command, this will only need to be run initially and when there are changes thereafter since the state of the database is persisted on the host as well.
This command is a helper which basically starts the sync service like
docker-sync start and then starts your compose stack like
docker-compose up in one single step.
host $ docker-sync-stack start
If everything was configured correctly, you should now be able to access your app on
Once you are done, you can call another helper which basically stops the sync-service like
docker-sync clean and also remove the application stack like
host $ docker-sync-stack clean
That’s a wrap. Let me know if you run into any unexpected issues.