Published in Development
Rails Development with Docker Without Losing AutoReloading and AutoLoading
Photo by Kyle Hanson on Unsplash

Rails Development with Docker Without Losing AutoReloading and AutoLoading

One of the most annoying things for me is working on a Rails project using Docker for development that is improperly configured, losing the automatic reloading of application constants and modules, forcing you to restart the Rails server each time you make a change.

As a freelancer and consultant, Docker allows me to work on multiple projects using different dependency versions in isolation. However, if Docker is not set up correctly, you can lose one of the most useful features of Rails development: module and constant autoloading and reloading. Losing the autoloading and reloading will drastically reduce development efficiency and increase frustration.

Do I need a different Dockerfile for development?

Yes, you do. When building the production image, I should bundle everything you need before deploying it into production.

Take the default Dockerfile generated when setting up a new Rails application, for instance.

File: Dockerfile

# syntax = docker/dockerfile:1

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.2.2
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base

# Rails app lives here
WORKDIR /rails

# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential git libpq-dev libvips pkg-config

# Set production environment
ENV RAILS_ENV="production" \
    NODE_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

# Throw-away build stage to reduce size of final image
FROM base as build

# Install packages needed to build gems and node modules
RUN apt-get install --no-install-recommends -y  curl libvips postgresql-client

# Install JavaScript dependencies
ARG NODE_VERSION="18.16.0"
ARG YARN_VERSION="1.22.18"
ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
    /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
    npm install -g yarn@$YARN_VERSION && \
    rm -rf /tmp/node-build-master

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git

# Install node modules
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# Copy application code
COPY . .

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

# Final stage for app image
FROM base

# Clean up installation packages to reduce image size
RUN rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER 1000:1000

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

File: bin/docker-entrypoint

#!/bin/bash -e

# Enable jemalloc for reduced memory usage and latency.
if [ -z "${LD_PRELOAD+x}" ] && [ -f /usr/lib/*/libjemalloc.so.2 ]; then
  export LD_PRELOAD="$(echo /usr/lib/*/libjemalloc.so.2)"
fi

# If running the rails server then create or migrate existing database
if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then
  ./bin/rails db:prepare
fi

exec "${@}"

You will notice:

  • it runs Rails in Production mode,
  • it uses a multi-stage build to reduce the image size,
  • it bundles and precompiles all the code and assets needed for the app,
  • and the entry point is used to prepare the database or run migrations.

However, in development:

  • I want Rails to reload your application files when you make changes so that I do not have to restart the server every time.
  • I want to avoid precompiling the asset pipeline and have the changes I make auto-reloaded.
  • I prefer to have all the GEMs and NPM packages managed by the image rather than installed locally; this makes it easier for me to manage multiple projects using different dependencies, avoiding RVM (https://rvm.io/) and NVM (https://github.com/nvm-sh/nvm) as much as possible.
  • The production image does none of these things, as the use case for the Docker image is very different.

How do you set up the Dockerfile for development?

My goal is to recreate the development experience Rails provides in Docker. I must reduce the production image to just the necessities needed to run the app and run the Rails app in the development environment.

File: Dockerfile-dev

# syntax = docker/dockerfile:1

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.2.2
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim

# Rails app lives here
WORKDIR /rails

# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential git nano libpq-dev libvips pkg-config

# Set production environment
ENV RAILS_ENV="development" \
    NODE_ENV="development" \
    BUNDLE_PATH="/usr/local/bundle"

# Install JavaScript dependencies
ARG NODE_VERSION="18.16.0"
ARG YARN_VERSION="1.22.18"
ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
    /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
    npm install -g yarn@$YARN_VERSION && \
    rm -rf /tmp/node-build-master

# Entrypoint prepares the database.
ENTRYPOINT ["./bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

File: bin/docker-entrypoint-dev

# Install missing gems
bundle check || bundle install --jobs 20 --retry 5

# Install npm packages
yarn install --network-timeout=30000

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

# Prepare the database
if [ "$RAILS_ENV" == "test" ]
then
  echo 'Preparing Test Environment Database'
  bundle exec rake test:prepare
fi

exec "${@}"

So what is going on here?

  1. I removed the multi-stage builds and focused on a single stage for the development environment.
  2. WORKDIR /rails sets the working directory, but we don’t COPY any application files into the image. Since the files are not copied over, I must set a volume for /rails from the local drive so the Rails app can run.
  3. RUN apt-get ... requires us to install all dependencies needed to run the Rails app and for debugging and diagnosing purposes.
  4. The entry point bin/docker-entrypoint-dev installs the missing GEMs and Packages before starting the server since they are not bundled into the development image. You can also include bundle exec rake db:prepare if you like, but I prefer to run these manually in development, so I tend to exclude this.

I aim to make the development environment as near production as possible without losing local development benefits.

How do you set up docker-compose.yml for development?

When setting up the local development using docker-compose.yml, I want to ensure our local files are used to run the server and that I can use the debug GEM when needed.

File: docker-compose.yml

version: '3.8'
volumes:
  app_gems:
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile-dev
    command: "./bin/dev"
    volumes:
      - .:/rails
      - app_gems:/usr/local/bundle/
    ports:
      - "3000:3000"
    stdin_open: true
    tty: true

So what is going on here?

  1. I explicitly specify which Dockerfile to use with dockerfile: Dockerfile-dev so that the development image is used and not the production image.
  2. I mount the local application code to the volume .:/rails so Rails can load the files from the local drive, not the Docker image.
  3. I cache the GEM bundles to the volume app_gems:/user/local/bundle so that we don’t have to reinstall all the GEMs each time we run the server. This is also useful when sharing GEMs between a web and worker container.
  4. To get the debug GEM to work correctly while developing locally, I need to set the following configurations to the Rails app container: stdin_open: true and tty: true.

Note: There is a known issue with some versions of Docker and OSs that prevents Docker from receiving the filed modified events. Forcing you to restart the container each time you make a change (better than rebuilding it, though). If so, you can update your Rails development configuration to use polling rather than events to autoreload changes. (see https://guides.rubyonrails.org/configuring.html#config-file-watcher for more details)

Final Thoughts

I have been using Docker for a while now, and I love it regarding local development. However, I can see why so many developers have turned from it, especially when images and containers are not configured correctly, which creates a poor development experience. Hopefully, this guide can help improve that and point others in the right direction.


comments powered by Disqus