Docker in development: Episode 2
The last episode was about motivation and the benefits of using docker for development. In this episode, we’ll dockerize a Ruby on Rails app.
Before we start:
You must be familiar with docker (as mentioned in the last episode) and docker-compose.
We know there are tons of “Dockerizing X” tutorials/blog posts out there. It’s not the goal of this post to present you the absolute tutorial/blog post, but to show you the problems we encountered while taking our first steps using docker, and how to solve them (as those aren’t easily googleable).
Step 1: Create a Dockerfile
As you know, the first step is to create a Dockerfile
in the root directory of our Rails app. The Dockerfile explains how the app is built and how it will be executed. For a standard Rails app, we usually have (more or less) this Dockerfile
:
FROM ruby:2.6
RUN apt-get update -qq && apt-get install -y build-essential curl git libpq-dev nodejs
WORKDIR /app
COPY . /app
COPY bin/entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
RUN bundle install
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]
If you’re familiar with docker (and we encouraged that since the beginning of the post), you’ll understand all of it. There’s nothing out of the ordinary in this Dockerfile, but one thing that you wouldn’t find in a regular Rails project is the entrypoint. The entrypoint is the main command of the image and is often used in combination with helper scripts.
In our case, for a Rails app, a normal bin/entrypoint.sh
file would look like this:
#!/bin/sh
set -e
# Remove a potentially pre-existing server.pid for Rails.
rm -f /app/tmp/pids/server.pid
exec "$@"
Let’s start from the end of the script. $@
means “All of the parameters passed to the script”. If we look at the Dockerfile
above, that is rails server -b 0.0.0.0
, meaning that exec rails server -b 0.0.0.0
will be executed within the entrypoint. The question here is: Why do we need the entrypoint then? And the answer is that container might have a pidfile that could cause the cmd to fail. That’s why rm -f /app/tmp/pids/server.pid
needs to exist in the entrypoint.
Are we ready to build our image and start running our app? No, we aren’t. We still haven’t defined what our database is, where it is, and we haven’t set up our config/database.yml
file. In our case, we’ll use a Postgres database. The reason why we didn’t do that yet is because we don’t want the database to be part of our app setup. If we do so, for example, by installing it in our image, then our data won’t be persistent across different executions (technically, you can achieve that by mounting volumes and such, but it goes against best practices). This is where docker-compose
comes into play.
version: "3"
services:
db:
image: postgres
volumes:
- ./db/postgresql-data:/var/lib/postgresql/data
web:
build: .
depends_on:
- db
ports:
- 3000:3000
volumes:
- .:/app
Step 2: docker-compose
As mentioned before, we don’t want our database to run separately from our codebase. In a typical Rails app, we’d have this docker-compose.yml file in the root directory of our Rails app:
Again, if you’re familiar with docker-compose
, it’s very easy to understand. We defined 2 services, the app itself (web
) and the Postgres database (db
). The key here is that we’re mounting 2 volumes: .
(our code) will be mounted in /app
in our container, and ./db/postgresql-data
will be mounted in our db service in /var/lib/posgresql/data
. This is important as we want our db to be persisted across executions. In other words, we don’t want to run rails db:create db:migrate
every time we start our containers. Also, be aware that you don’t want ./db/postgresql-data
to be versioned, so make sure you ignore it.
The next step is to set up config/database.yml
. We’ll do it this way:
default: &default
adapter: postgresql
pool: 15
timeout: 5000
username: postgres
host: db
development:
<<: *default
database: app_development
test: &test
<<: *default
database: app_test
production:
<<: *default
database: app_production
Note that this was redacted to keep it short. The important bits here are the username
, set to postgresql
, and the host
, set to db
. Nothing here is random: postgres
is the username the postgres image defined for the username, and db
is the name of the service defined in our docker-compose.yml
file. If you want to find out more about the postgres image, you can do that by reading Postgres’ official image docs.
Are we ready to build our image and start running our app? Technically, yes, but let’s do one more thing.
Step 3: .dockerignore
You might guess what a .dockerignore
file is. If you thought it was a file that ignores the files and directories listed there when copying from the current context to the image, then you guessed correctly. We don’t want certain files to be copied onto our image to keep it small. In our typical Rails app, we’d have the following .dockerignore
file:
.git
db/postgresql-data
log
Are we ready to build our image and start running our app? Yes! Now we are.
Step 4: Building and running
Now that everything is in place we can build our image and run our containers!
You can build your images and start the app by running:
$ docker-compose up --build
or the same command without --build
, as it would do it by default if the images don’t exist.
By the time it finishes, you should be up and running, and able to see your app at localhost:3000.
Join us in the next episode!