Dockerizing a Ruby on Rails app with a PostgreSQL Database

Posted by in Rails, last updated on 06 September, 2020

Introduction

These are the configurations I use to deploy a Rails 6 app. Note that this is a small hobby app so I’m using docker-compose to deploy it, for more serious apps you’d want to use docker swarm or kubernetes.

If you don’t have a server ready or docker and docker-compose installed on your local dev machine then have a read through this blog post first.

For this build I’m not going to dockerize NGINX as I have it installed on the server.

Create a Dockerfile

Step one is to create a Dockerfile in the root directory of your project. For this build I’ll be using ruby:2.6.5-alpine3.11 image. alpine is a much slimmer Linux docker image and will reduce our overall image sizes.

# Extend from the official Ruby image
FROM ruby:2.6.5-alpine3.11
ENV RAILS_ENV production
ENV RAILS_SERVE_STATIC_FILES true
ENV RAILS_LOG_TO_STDOUT true

# Install required libraries on Alpine
# note: build-base required for nokogiri gem
# note: postgresql-dv required for pg gem
RUN apk update && apk upgrade && \
  apk add tzdata postgresql-dev && \
  apk add postgresql-client && \
  apk add nodejs yarn && \
  apk add build-base

# Throw errors if Gemfile has been modified since Gemfile.lock
RUN bundle config --global frozen 1

# Copy Gemfile so we can cache gems
COPY Gemfile Gemfile.lock ./

# Fix issue with sassc gem
RUN bundle config --local build.sassc --disable-march-tune-native

# Install Ruby gems
RUN bundle install --without development test

# Copy all application files
COPY . .

# Precompile assets
RUN SECRET_KEY_BASE=`bundle exec rails secret` bundle exec rails assets:precompile

# Run entrypoint.sh script
RUN chmod +x entrypoint.sh
CMD ["/entrypoint.sh"]

In order to precompile the assets during the image build a SECRET_KEY_BASE needs to be set. We don’t want to hard-code a value into our Dockerfile though as we’ll set this later via an environment variable, so this is a “hack” to get the build to pass. If you know of a better way please let me know.

Note: if you’re only planning to use this setup to test your app locally then don’t set the RAILS_ENV to production.

Note that you’ll only need the RUN bundle config --local build.sassc --disable-march-tune-native line if you’re running into the following error:

/usr/local/bundle/gems/ffi-1.11.1/lib/ffi/library.rb:112:  at 0x00007fdf4c5d7575
ruby 2.6.5p114 (2019-10-01 revision 67812) [x86_64-linux-musl]
-- Control frame information -----------------------------------------------
c:0084 p:---- s:0473 e:000472 CFUNC  :open
c:0083 p:0022 s:0467 e:000466 BLOCK  /usr/local/bundle/gems/ffi-1.11.1/lib/ffi/library.rb:112 [FINISH]
c:0082 p:---- s:0458 e:000457 CFUNC  :each
c:0081 p:0113 s:0454 e:000453 BLOCK  /usr/local/bundle/gems/ffi-1.11.1/lib/ffi/library.rb:109 [FINISH]
c:0080 p:---- s:0447 e:000446 CFUNC  :map
c:0079 p:0069 s:0443 e:000442 METHOD /usr/local/bundle/gems/ffi-1.11.1/lib/ffi/library.rb:99
c:0078 p:0079 s:0436 e:000435 CLASS  /usr/local/bundle/gems/sassc-2.2.1/lib/sassc/native.rb:11
c:0077 p:0007 s:0432 e:000431 CLASS  /usr/local/bundle/gems/sassc-2.2.1/lib/sassc/native.rb:6
c:0076 p:0014 s:0429 e:000428 TOP    /usr/local/bundle/gems/sassc-2.2.1/lib/sassc/native.rb:5 [FINISH]

...

[NOTE]
You may have encountered a bug in the Ruby interpreter or extension libraries.
Bug reports are welcome.
For details: https://www.ruby-lang.org/bugreport.html

Aborted (core dumped)
/usr/local/bundle/gems/ffi-1.11.1/lib/ffi/library.rb:112: [BUG] Illegal instruction at 0x00007f128c9ec575
ruby 2.6.5p114 (2019-10-01 revision 67812) [x86_64-linux-musl]

...

You can read more about this error here.

Create a docker-compose.yml file

Next create a docker-compose.yml file in the root directory of your project.

To find out which version you can define at the top of your file check docker -v and compare to the version reference here.

version: "3.7"

services:
  rails:
    build:
      context: .
    env_file:
      - rails.env
    environment:
      DB_USERNAME: your_db_user
      DB_DATABASE: your_db_name
      DB_PORT: 5432
      DB_HOST: db
      RAILS_MAX_THREADS: 5
      PORT: 3000
    ports:
      - 3000:3000
    restart: always
    depends_on:
      - "db"

  db:
    image: postgres:10.12-alpine
    env_file:
      - db.env
    environment:
      POSTGRES_USER: your_db_user
      PGDATA: /var/lib/postgresql/data/pgdata
    restart: always
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

I’ve set both the rails app and the db (PostgreSQL) to always restart with restart: always. If the container crashes or you reboot the server these services will start up automatically.

The Rails app depends on the database being up to run, we’ve set this with the depends on: - db line. We’re also creating a pgdata volume so the database data is persisted.

Note that we’re using an env_file: to set some environment variables as well as environment: for other variables. You can choose which variables you want committed to your version control system and which not to commit.

Create the .env files

Now we’ll create rails.env and db.env, you can name the files anything as long as they match the configuration in your docker-compose.yml file.

The rails.env file:

DB_PASSWORD=your_db_password
SECRET_KEY_BASE=rails_secret_key # generate this with 'rails secret'

The db.env file

POSTGRES_PASSWORD=your_db_password

Add the .env files to your .gitignore

We don’t want to commit our secret keys to a version control system so add the following to your .gitignore file:

# Ignore environment files
*.env

Create a .dockerignore file

When copying our files into the docker container we don’t want folders such as node_modules to be copied. Also we don’t want any files with sensitive information such as .env or our master.key to be copied into the container.

You can use the file below as an example:

# Ignore bundler config.
.bundle

# Ignore all logfiles and tempfiles.
log/
tmp/

# Ignore uploaded files in development.
storage/
public/assets

# Byebug history
.byebug_history

# Ignore master key for decrypting credentials and more.
config/master.key

# Node modules, yarn etc.
public/packs
public/packs-test
node_modules
yarn-error.log
yarn-debug.log*
.yarn-integrity

# Ignore environment files
*.env

# Ignore database dumps
*.dump

# Misc files
.dockerignore
.git
.cache

Create an entrypoint file

Create an entrypoint.sh file in the project’s root directory:

#!/bin/sh

# Note: !/bin/sh must be at the top of the line,
# Alpine doesn't have bash so we need to use shell.
# Docker entrypoint script.
# Don't forget to give this file execution rights via `chmod +x entrypoint.sh`
# which I've added to the Dockerfile but you could do this manually instead.

# Wait until Postgres is ready before running the next step.
while ! pg_isready -q -h $DB_HOST -p $DB_PORT -U $DB_USERNAME
do
  echo "$(date) - waiting for database to start."
  sleep 2
done

# If the database exists, migrate. Otherwise setup (create and migrate)
echo "Running database migrations..."
bundle exec rails db:migrate 2>/dev/null || bundle exec rails db:create db:migrate
echo "Finished running database migrations."

# Remove a potentially pre-existing server.pid for Rails.
echo "Deleting server.pid file..."
rm -f /tmp/pids/server.pid

# Start the server.
echo "Starting rails server..."
bundle exec rails server

Although we’ve set depends_on: - db in our docker-compose.yml file, this only waits for the PostgreSQL container to run, however the database will still be starting up and we don’t want to try running any migrations etc. until it’s ready to accept connections hence the while ! pg_isready line.

Note: Alpine doesn’t have bash installed so make sure your entrypoint.sh is a shell script, or you can add apk add bash to your Dockerfile.

Shoutout: migrations check taken from here.

Configure the Rails app production settings

Before we can spin up the containers with docker-compose up we also need to configure the app’s database in config/database.yml:

production:
  <<: *default
  database: <%= ENV['DB_DATABASE'] %>
  username: <%= ENV['DB_USERNAME'] %>
  password: <%= ENV['DB_PASSWORD'] %>
  host: <%= ENV['DB_HOST'] %>

We’re mapping the environment variables to the ones we set in the docker-compose.yml file.

Note: if you’re only planning to use this setup to test your app locally then don’t change the production settings in your database.yml but the development settings instead.

Testing the build in development

To test everything is working properly before deploying you can run docker-compose up --build and visit http://localhost:3000.

Note that this will run your app in production mode so if you want a more elegant solution you can create multiple compose files and pass in your build arguments via the compose file.

Deploying the app

If you’re planning on deploying the app using docker-compose and docker contexts then follow this blog post. Again I only recommend this for smaller hobby apps, e.g. a personal blog, and not applications that require high availability and rolling upgrades - for that look to using docker swarm or kubernetes.

If this is your first time containerizing an app then docker-compose is a good way of getting started however.


Daniel Wachtel

Written by Daniel Wachtel

Daniel is a Full Stack Engineer who outside work hours is usually found working on side projects or blogging about the Software Engineering world.