Intro

In this post, I will show you how to Dockerize your Rails 7 app in a development environment. We will be using Tailwind for the CSS and PostgreSQL for the database. This setup includes file syncing of assets on file changes between the host and container which is super nice.

Software used in this post

  • Docker - 20.10.18, build b40c2f6
  • Ruby - 3.1.2p20
  • Rails - 7.0.4
  • PostgreSQL - 14.1

Code Repository

The code for this blog can be found on Github here.

Environment Variables

Before we begin, add some environment variables to your rc file. This allows your user and group ID's to be used within the containers.

~/.zshrc
export UID=$(id -u)
export GID=$(id -g)

Dockerfiles

Create the following Dockerfile which is used to create our application container images.

Dockerfile.rails
################## RAILS BUILD IMAGE ################## 
# Use this to generate a new rails application
FROM ruby:3.1.2-alpine3.16 AS build

# ARGs are passed in via the `--build-arg ` CLI argument
ARG APP_NAME
ARG APP_USER
ARG APP_USER_ID
ARG APP_GROUP_ID
ENV APP_NAME ${APP_NAME}
ENV APP_USER ${APP_USER}
ENV APP_USER_ID ${APP_USER_ID}
ENV APP_GROUP_ID ${APP_GROUP_ID}

# Static variables
ARG BUILD_PACKAGES="build-base"

# Install build deps
RUN echo "http://dl-4.alpinelinux.org/alpine/v3.16/main" >> /etc/apk/repositories \
  && echo "http://dl-4.alpinelinux.org/alpine/v3.16/community" >> /etc/apk/repositories \
  && apk update \
  && apk add ${BUILD_PACKAGES}

# Set working directory
WORKDIR /opt

# Install Rails
RUN gem install rails bundler --no-document

# Create new Rails app
RUN rails new \
  --skip-bundle \
  --skip-git \
  --database=postgresql \
  --css=tailwind \
  ${APP_NAME}

WORKDIR /opt/${APP_NAME}

################## RAILS BASE IMAGE ################## 
FROM ruby:3.1.2-alpine3.16 AS rails-base

ARG APP_NAME
ARG APP_USER
ARG APP_USER_ID
ARG APP_GROUP_ID
ENV APP_NAME ${APP_NAME}
ENV APP_USER ${APP_USER}
ENV APP_USER_ID ${APP_USER_ID}
ENV APP_GROUP_ID ${APP_GROUP_ID}

ARG RUN_PACKAGES="build-base tzdata postgresql-dev postgresql-client nodejs yarn"

RUN echo "http://dl-4.alpinelinux.org/alpine/v3.12/main" >> /etc/apk/repositories \
  && echo "http://dl-4.alpinelinux.org/alpine/v3.12/community" >> /etc/apk/repositories \
  && apk update \
  && apk add --no-cache $RUN_PACKAGES

# Default directory
RUN mkdir -p /opt/${APP_NAME}
WORKDIR /opt/${APP_NAME}

COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /opt/${APP_NAME} /opt/${APP_NAME}
COPY config/Gemfile Gemfile

RUN bundle install \
  && bin/rails tailwindcss:install

COPY config/Procfile.dev Procfile.dev
COPY config/database.yml config/database.yml
COPY config/environments-development.rb config/environments/development.rb
COPY config/bin-dev bin/dev
RUN chmod +x bin/dev

# Cleanup cache
RUN bundle clean --force \
  && rm -rf /usr/local/bundle/cache/*.gem \
  && find /usr/local/bundle/gems/ -name "*.c" -delete \
  && find /usr/local/bundle/gems/ -name "*.o" -delete

# Create app user and group
RUN addgroup -S ${APP_USER} -g ${APP_GROUP_ID}  && adduser -u ${APP_USER_ID} -S ${APP_USER} -G ${APP_USER}

# Set directory ownership
RUN chown -R ${APP_USER_ID}:${APP_GROUP_ID} /opt/${APP_NAME}

USER ${APP_USER}

# Add a script to be executed every time the container starts.
EXPOSE 3000
CMD ["bin/dev"]

The following base docker-compose.yaml file is used for all stages of development.

docker-compose.yaml
version: "3.9"
services:
  db:
    image: postgres:14.1
    volumes:
      - ./tmp/db:/var/lib/postgresql/data
    environment:
      - "POSTGRES_USER=${PGS_USER}"
      - "POSTGRES_PASSWORD=${PGS_PASS}"
  app:
    build:
      context: .
      dockerDockerfile
    image: rails-base
    user: ${APP_USER_ID}:${APP_GROUP_ID}
    volumes:
      - ./${APP_NAME}:/opt/${APP_NAME}
    depends_on:
      - db
    environment:
      - "APP_NAME=${APP_NAME}"
      - "APP_USER_ID=${APP_USER_ID}"
      - "APP_GROUP_ID=${APP_GROUP_ID}"
      - "PGS_HOST=${PGS_HOST}"
      - "PGS_USER=${PGS_USER}"
      - "PGS_PASS=${PGS_PASS}"
      - "RAILS_ENV=${RAILS_ENV}"

The docker-compose-dev.yaml file is used for the development environment.

docker-compose-dev.yaml
version: "3.9"
services:
  app:
    user: ${APP_USER_ID}:${APP_GROUP_ID}
    command: sh -c "bin/rails db:create && bin/rails db:migrate && rm -f tmp/pids/server.pid && bundle exec bin/dev"
    ports:
      - "3000:3000"

The .env.dev file is used to pass environment variables to containers.

.env.dev
APP_NAME=<app-name>
APP_USER=$USER
APP_USER_ID=$UID
APP_GROUP_ID=$GID
PGS_HOST=db
PGS_USER=$USER
PGS_PASS=$USER
RAILS_ENV=development

Configuration Files

To get our Rails environment working well with Docker, we need to alter some of the configuration files. The alterations are explained below.

The configuration files can be found found in the Github repo. in the config directory.

The Gemfile file has the foreman gem added which is used to run the Procfile.

Gemfile
source "https://rubygems.org"
gem "foreman", "~> 0.87.2"
# ... rest of file

The config/database.yml file is altered to pull data from environment variables.

config/database.yml
default: &default
  adapter: postgresql
  encoding: unicode
  # For details on connection pooling, see Rails configuration guide
  # https://guides.rubyonrails.org/configuring.html#database-pooling
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  host: <%= ENV.fetch("PGS_HOST") { "db" } %>
  username: <%= ENV.fetch("PGS_USER") { "postgres" } %>
  password: <%= ENV.fetch("PGS_PASS") { "postgres" } %>

development:
  <<: *default
  database: <%= "#{ENV.fetch('APP_NAME')}_development" %>

test:
  <<: *default
  database: <%= "#{ENV.fetch('APP_NAME')}_test" %>

production:
  <<: *default
  database: <%= "#{ENV.fetch('APP_NAME')}_production" %>

The config/environments-development.rb file is altered to allow access to IPv4/6 address and any hostname for development mode.

config/application-development.rb
Rails.application.configure do

  # Allow access from any IPv4/6 address
  config.web_console.whitelisted_ips = ['0.0.0.0/0', '::/0']

  # Allow access from any hostname
  config.hosts.clear

  # ... rest of file
end

Alpine linux does not have the bash shell. The shebang line of the bin/dev file is updated to use the sh shell.

bin/dev
#!/usr/bin/env sh
# ... rest of file

The Procfile.dev file is updated to bind to all IPv4 addresses (0.0.0.0) instead of localhost.

Procfile.dev
web: bin/rails server -b "0.0.0.0" -p 3000
# ... rest of file

Build Containers

Build the rails-base container image.

cmd
docker compose \
  -f docker-compose.yaml \
  -f docker-compose-dev.yaml \
  --env-file .env.dev \
  build \
    --build-arg APP_NAME=<app-name> \
    --build-arg APP_USER_ID=$UID \
    --build-arg APP_GROUP_ID=$GID \
    --build-arg APP_USER=$USER

Generate Rails App

Generate a new Rails app using the rails-base container image.

cmd
export APP_NAME="<app-name>" \
  && docker container run -itd --name=rails-tmp rails-base sh \
  && docker container cp rails-tmp:/opt/$APP_NAME $APP_NAME \
  && docker container kill rails-tmp \
  && docker container rm rails-tmp

This generates a Rails application in the <app-name> directory.

Run Containers

Bring up the application containers.

cmd
docker compose \
  -f docker-compose.yaml \
  -f docker-compose-dev.yaml \
  --env-file .env.dev \
  up

Bring down the application containers.

cmd
docker compose \
  -f docker-compose.yaml \
  -f docker-compose-dev.yaml \
  --env-file .env.dev \
  down

Run a generator.

cmd
docker compose run \
  --user $UID:$GID \
  app \
  bin/rails generate scaffold device name:string

Outro

In this post I showed you how to configure a Docker environment for Ruby on Rails application development. It's a bit of a process but works well when all the peices are together. Look out for a future post where I build a production deployment.