Rails-docker-box, or developing Rails within a dockerized environment
Tags: rails, docker, opensource
Published: 2022-03-22 13:13:25

> ⚠️ Attention! This post is **not about using Docker to develop Rails applications**, but about using Docker to develop the Rails framework itself. For the former one, please, visit the [Ruby on Whales article][ruby-on-whales].

## From boxes to containers, or a bit of history

I've been contributing to Rails (from time to time) since 2015. Developing such a massive framework as Rails very differs from working on a web application built with it. First of all, you need to cover all the possible configurations: databases, cache servers, etc.; many system dependencies (e.g., `libxml2` or `ffmpeg`) must be installed.

Secondly, unlike for a private project, where each team member **has to deal with this setup**, an open-source project should be...hm..._open_ for everyone willing to contribute. The more complicated it is to configure a proper environment the more likely potential contributors would give up. And we don't want this, right?

Luckily, the Rails team (and [Xavier Noria](https://github.com/fxn) in particular) found a way to solve this problem—[rails-dev-box][]. Rails Dev Box is a [Vagrant][] configuration, which allows you to spin up a virtual machine with everything you need inside. Cool, right?

Yeah, that was really cool. In 2015.

I gave up on VM-based development in 2017, when I found that running a VM along with a couple of Electron-based apps no longer fit my laptop. I turned to containers.

Since then, I started using Docker not only for [applications development][ruby-on-whales] but also for hacking around with Rails.

Since I mostly dealt with Active Record and Action Cable, my Docker configuration wasn't complete. Also, back in the days, the Rails codebase wasn't _container-friendly_ (e.g., some tests relied on a Redis or PostgreSQL instance running on the same machine). Thus, I just kept my setup around (in [a few commits](https://github.com/palkan/rails/commits/chore/dockerize-dev)], and haven't tried to promote to the upstream or whatever.

Lately, I've been working [a new PR to Action Cable](https://github.com/rails/rails/pull/44696) and had to re-visit my configuration (since many things have changed in the last year). I liked what I got in the end, so I decided to share it with the community.

Below is the _compatibility table_—which libraries are currently supported (i.e., `rake test` passes):

- actioncable ✅
- actionmailbox ✅
- actionmailer ✅
- actionpack ✅
- actiontext ✅
- actionview ✅
- activejob ✅
- activemodel ✅
- activerecord:
  - `rake test:sqlite3` ✅ ⚠️: `26761 assertions, 2 failures, 2 errors, 27 skips` (`sqlite3: not found`)
  - `rake test:postgresql` ✅ ⚠️: `28766 assertions, 0 failures, 2 errors, 18 skips` (`couldn't connect to /var/run/postgresql/.s.PGSQL.5432`)
  - `rake test:mysql2` 🚫 (no `mysql` database configured)
- activestorage ⚠️ (_some system deps missing_)
- activesupport ✅ ⚠️ (evented file checker tests fail 🤔)
- railties 🚫 (`No such file or directory - yarn`

**NOTE:** JavaScript tests are not supported (no Node/Yarn env configured).

## Docker, Compose, and Dip walks into a bar

It's all started with just two files: `Dockerfile` and `docker-compose.yml`. Although that was good enough to "build" a project and run tests, the overall developer experience was barely satisfying.

So, I went the old-fashioned way and added [Dip][] to the mix.
Now I can run all the familiar commands (`bundle`, `rake`, etc.) from my host system (with a `dip ` prefix) without thinking about all the `docker-compose --rm --it bla-bla`. Moreover, I can `cd` into a subfolder (say, `actioncable`), and execute commands from there just like on a host machine:

```sh
# Installing deps at the project's root level
dip bundle install
# Run all Rails tests (I never tried 🙂)
dip rake test

# That's what I usually do
cd actioncable
# Install Action Cable dev deps
dip bundle
# Run Action Cable tests
dip rake
# Or run a particular test file
dip test test/connection/streams_test.rb
```

The `dip test` command is an alias for `bundle exec ruby -Ilib:test`—and that's my favourite one ♥️

Want to give this setup a try? You can grab it right from this post (or from the [gist](https://gist.github.com/palkan/f745f4da3e75d4bcf5bbb3bc3f6a4661)!

Here is the configuration I keep at the project's root:

```txt
.dockerdev/
  Aptfile
  .bashrc
  Dockerfile
  compose.yml
  # Active Record configs
  config.yml
<some rails files>
dip.yml
```

And the contents of all the files:

- `.dockerdev/Dockerfile`

```dockerfile
ARG RUBY_VERSION
ARG DISTRO_NAME=bullseye

FROM ruby:$RUBY_VERSION-slim-$DISTRO_NAME
ARG DISTRO_NAME

# Common dependencies
RUN apt-get update -qq \
  && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    build-essential \
    gnupg2 \
    curl \
    less \
    git \
  && apt-get clean \
  && rm -rf /tmp/* /var/tmp/* \
  && truncate -s 0 /var/log/*log

ARG PG_MAJOR
RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \
  && echo deb http://apt.postgresql.org/pub/repos/apt/ $DISTRO_NAME-pgdg main $PG_MAJOR > /etc/apt/sources.list.d/pgdg.list
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
  DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    libpq-dev \
    postgresql-client-$PG_MAJOR && \
    apt-get clean && \
    rm -rf /tmp/* /var/tmp/* && \
    truncate -s 0 /var/log/*log

# Application dependencies
# We use an external Aptfile for this, stay tuned
COPY Aptfile /tmp/Aptfile
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
  DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    $(grep -Ev '^\s*#' /tmp/Aptfile | xargs) && \
    apt-get clean && \
    rm -rf /tmp/* /var/tmp/* && \
    truncate -s 0 /var/log/*log

ENV LANG C.UTF-8
ENV GEM_HOME /bundle
ENV BUNDLE_PATH=$GEM_HOME \
  BUNDLE_APP_CONFIG=$BUNDLE_PATH \
  BUNDLE_BIN=$BUNDLE_PATH/bin \
  BUNDLE_JOBS=4 \
  BUNDLE_RETRY=3
ENV PATH /app/bin:$BUNDLE_BIN:$PATH

ARG BUNDLER_VERSION
RUN gem update --system && \
    gem install bundler

RUN mkdir -p /app
WORKDIR /app

CMD ["/usr/bin/bash"]
```

- `.dockerdev/Aptfile`

```
vim
# Build tools
autoconf
libtool
libncurses5-dev
libxml2-dev
# ActiveRecord deps
libsqlite3-dev
default-libmysqlclient-dev
```

- `.dockerdev/compose.yml`

```yml
x-app: &app
  build:
    context: .
    args:
      RUBY_VERSION: '3.0.2'
      PG_MAJOR: '14'
  image: rails-dev:7.1.0
  tmpfs:
    - /tmp

services:
  runner:
    <<: *app
    stdin_open: true
    tty: true
    volumes:
      - ..:/app:cached
      - bundle:/bundle
      - history:/usr/local/hist
      - ./.psqlrc:/root/.psqlrc:ro
      - ./.bashrc:/root/.bashrc:ro
    environment:
      REDIS_URL: redis://redis:6379/
      DATABASE_URL: postgres://postgres:postgres@postgres/
      HISTFILE: /usr/local/hist/.bash_history
      XDG_DATA_HOME: /app/tmp/caches
      EDITOR: vi
      # Use PostgreSQL by default for AR tests
      ARCONN: ${ARCONN:-postgresql}
    working_dir: ${WORK_DIR:-/app}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  postgres:
    image: postgres:14
    volumes:
      - .psqlrc:/root/.psqlrc:ro
      - postgres:/var/lib/postgresql/data
      - history:/user/local/hist
    environment:
      PSQL_HISTFILE: /user/local/hist/.psql_history
      POSTGRES_PASSWORD: postgres
      # For createdb
      PGPASSWORD: postgres
    ports:
      - 5432
    healthcheck:
      test: pg_isready -U postgres -h 127.0.0.1
      interval: 5s

  redis:
    image: redis:6.2-alpine
    volumes:
      - redis:/data
    ports:
      - 6379
    healthcheck:
      test: redis-cli ping
      interval: 1s
      timeout: 3s
      retries: 30

volumes:
  history:
  postgres:
  redis:
  bundle:
```

- `.dockerdev/.bashrc` (put your favorite Bash extensions there)

```
alias be="bundle exec"
```

- `dip.yml`

```yml
version: '7.1'

environment:
  WORK_DIR: /app/${DIP_WORK_DIR_REL_PATH}

compose:
  files:
    - .dockerdev/compose.yml
  project_name: rails_dev

interaction:
  # This command spins up a Rails container with the required dependencies (such as databases),
  # and opens a terminal within it.
  runner:
    description: Open a Bash shell within a Rails container (with dependencies up)
    service: runner
    command: /bin/bash

  # Run a Rails container without any dependent services (useful for non-Rails scripts)
  bash:
    description: Run an arbitrary script within a container (or open a shell without deps)
    service: runner
    command: /bin/bash
    compose_run_options: [ no-deps ]

  # A shortcut to run Bundler commands
  bundle:
    description: Run Bundler commands
    service: runner
    command: bundle
    compose_run_options: [ no-deps ]

  rake:
    description: Run Rake commands
    service: runner
    command: bundle exec rake

  ruby:
    description: Run Ruby with Bundler activated
    service: runner
    command: bundle exec ruby

  rubocop:
    description: Run RuboCop
    service: runner
    command: bundle exec rubocop
    compose_run_options: [ no-deps ]

  test:
    description: Run a single test file (an alias for ruby -Ilib:test)
    service: runner
    command: bundle exec ruby -Ilib:test

  psql:
    description: Run Postgres psql console
    service: postgres
    default_args: anycasts_dev
    command: psql -h postgres -U postgres

  createdb:
    description: Create a PostgreSQL database
    service: postgres
    command: createdb -h postgres -U postgres

  'redis-cli':
    description: Run Redis console
    service: redis
    command: redis-cli -h redis

provision:
  - dip compose down --volumes
  - dip compose up -d postgres redis
  - dip bundle install
  - (test -f activerecord/test/config.yml) || (cp .dockerdev/config.yml activerecord/test/config.yml)
  - dip createdb activerecord_unittest
  - dip createdb activerecord_unittest2
```

### Bonus: Git ignore without `.gitignore`

The final question: since we keep it in the project's directory, and this is not an official setup (at least, yet), we need to make sure we do not accidentally commit it to the repo. In other words, how to Git-ignore our configuration without updating the `.gitignore` file? And the answer is—`.git/info/exclude`. That's a specific, local, Git configuration file, which works similarly to `.gitignore`. So, just open this file (say, `code .git/info/exclude`) and drop the following lines:

```txt
# .git/info/exclude

dip.yml
.dockerdev/
```

That's it!

P.S. For hacking with Ruby (MRI), I also have a dockerized environment: [ruby-dip][].

[ruby-on-whales]: https://evilmartians.com/chronicles/ruby-on-whales-docker-for-ruby-rails-development
[rails-dev-box]: https://github.com/rails/rails-dev-box
[Vagrant]: https://www.vagrantup.com
[Dip]: https://github.com/bibendi/dip
[ruby-dip]: https://github.com/palkan/ruby-dip