Ruby on Whales

Dockerizing Ruby on Rails environment
Icons/chart bar
Used 1775 times
Created by
V Vladimir Dementyev

Usage
This template configure a Dockerized development environment for Rails applications following the "Ruby on Whales" article.

Run this command in your Rails app directory in the terminal:

rails app:template LOCATION="https://railsbytes.com/script/z5OsoB"
Template Source

Review the code before running this template on your machine.

say "👋 Welcome to interactive Ruby on Whales installer 🐳.\n" \
    "Make sure you've read the guide: https://evilmartians.com/chronicles/ruby-on-whales-docker-for-ruby-rails-development"

DOCKER_DEV_ROOT = ".dockerdev"

# Prepare variables and utility files
# Collect the app's metadata

app_name = Rails.application.class.name.partition('::').first.parameterize
# Load the project's deps and required Ruby version

ruby_version = nil
gemspecs = {}

begin
  if File.file?("Gemfile.lock")
    bundler_parser = Bundler::LockfileParser.new(Bundler.read_file("Gemfile.lock"))
    gemspecs =  Hash[bundler_parser.specs.map { |spec| [spec.name, spec.version] }]
    maybe_ruby_version = bundler_parser.ruby_version&.match(/ruby (\d+\.\d+\.\d+)./i)&.[](1)
  end

  begin
    if maybe_ruby_version
      ruby_version = ask("Which Ruby version would you like to use? (Press ENTER to use #{maybe_ruby_version})") || ""
      ruby_version = maybe_ruby_version if ruby_version.empty?
    else
      ruby_version = ask("Which Ruby version would you like to use? (For example, 3.2.0)") || ""
    end

    Gem::Version.new(ruby_version)
  rescue ArgumentError
    say "Invalid version. Please, try again"
    retry
  end
end
# Generates the Aptfile with system deps
DEFAULT_APTFILE = <<~CODE
  # An editor to work with credentials
  vim
CODE

begin
  deps = []
  loop do
    dep = ask("Which system package do you want to install? (Press ENTER to continue)") || ""
    break if dep.empty?
    deps << dep
  end

  aptfile = File.join(DOCKER_DEV_ROOT, "Aptfile")
  FileUtils.mkdir_p(File.dirname(aptfile))

  app_deps =
    if deps.empty?
      "\n"
    else
      (["# Application dependencies"] + deps + [""]).join("\n")
    end

  File.write(aptfile, DEFAULT_APTFILE + app_deps)
end
# Set up database related variables, create files

database_adapter = nil
database_url = nil

begin
  supported_adapters = %w(postgresql postgis postgres)

  config_path = "config/database.yml"

  if File.file?(config_path)
    require "yaml"
    maybe_database_adapter = begin
      ::YAML.load_file(config_path, aliases: true) || {}
    rescue ArgumentError
      ::YAML.load_file(config_path) || {}
    end.dig("development", "adapter")
  end

  selected_database_adapter =
    if maybe_database_adapter
      ask "Which database adapter do you use? (Press ENTER to use #{maybe_database_adapter})"
    else
      ask "Which database adapter do you use?"
    end

  selected_database_adapter = maybe_database_adapter if selected_database_adapter.empty?

  if supported_adapters.include?(selected_database_adapter)
    database_adapter = selected_database_adapter
  else
    say_status :warn, "Unfortunately, we do no support #{selected_database_adapter} yet. Please, configure it yourself"
  end
end
# Specify PostgreSQL version

postgres_version = nil
postgres_base_image = "postgres"

DEFAULT_POSTGRES_VERSION = "15"
POSTGRES_ADAPTERS = %w[postgres postgresql postgis]

if POSTGRES_ADAPTERS.include?(database_adapter)
  begin
    selected_postgres_version = ask "Which PostgreSQL version do you want to install? (Press ENTER to use #{DEFAULT_POSTGRES_VERSION})"

    postgres_version = selected_postgres_version.empty? ? DEFAULT_POSTGRES_VERSION : selected_postgres_version
    database_url = "#{database_adapter}://postgres:postgres@postgres:5432"

    if database_adapter == "postgis"
      postgres_base_image = "postgis/postgis"
    end
  end
end
# Node/Yarn configuration
# TODO: Read Node/Yarn versions from .nvmrc/package.json.

node_version = nil
yarn_version = nil

DEFAULT_NODE_VERSION = "18"

begin
  selected_node_version = ask(
    "Which Node version do you want to install? (Press ENTER to use #{DEFAULT_NODE_VERSION}, type 'n/no' to skip installing Node)"
  ) || ""

  unless selected_node_version =~ /^\s*no?\s*$/
    node_version = selected_node_version.empty? ? DEFAULT_NODE_VERSION : selected_node_version

    yarn_version = ask("Which Yarn version do you want to install? (Press ENTER to install the latest one, type 'n/no' to skip installing Yarn)") || ""
    yarn_version = "latest" if yarn_version.empty?
    yarn_version = false if yarn_version =~ /^\s*no?\s*$/
  end
end
# Redis info

redis_version = nil

DEFAULT_REDIS_VERSION = "7.0"

begin
  if gemspecs.key?("redis")
    maybe_redis_version = ask "Which Redis version do you want to use? (Press ENTER to use #{DEFAULT_REDIS_VERSION})"

    redis_version = maybe_redis_version.empty? ? DEFAULT_REDIS_VERSION : maybe_redis_version
  end
end

# Generate configuration
file "#{DOCKER_DEV_ROOT}/Dockerfile", ERB.new(
    *[
  <<~'TCODE'
# syntax=docker/dockerfile:1

ARG RUBY_VERSION
ARG DISTRO_NAME=bullseye

FROM ruby:$RUBY_VERSION-slim-$DISTRO_NAME

ARG DISTRO_NAME

# Common dependencies
# Using --mount to speed up build with caching, see https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mount
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
  --mount=type=cache,target=/var/lib/apt,sharing=locked \
  --mount=type=tmpfs,target=/var/log \
  rm -f /etc/apt/apt.conf.d/docker-clean; \
  echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache; \
  apt-get update -qq && \
  DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
  DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    build-essential \
    gnupg2 \
    curl \
    less \
    git
<% if postgres_version %>

ARG PG_MAJOR
RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgres-archive-keyring.gpg \
  && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/postgres-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt/" \
    $DISTRO_NAME-pgdg main $PG_MAJOR | tee /etc/apt/sources.list.d/postgres.list > /dev/null
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
  --mount=type=cache,target=/var/lib/apt,sharing=locked \
  --mount=type=tmpfs,target=/var/log \
  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
<% end %>
<% if node_version %>

ARG NODE_MAJOR
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
  --mount=type=cache,target=/var/lib/apt,sharing=locked \
  --mount=type=tmpfs,target=/var/log \
  curl -sL https://deb.nodesource.com/setup_$NODE_MAJOR.x | bash - && \
  DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    nodejs
<% if yarn_version == 'latest' %>

RUN npm install -g yarn
<% elsif yarn_version %>

ARG YARN_VERSION
RUN npm install -g yarn@$YARN_VERSION
<% end %>
<% end %>

# Application dependencies
# We use an external Aptfile for this, stay tuned
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    --mount=type=tmpfs,target=/var/log \
    --mount=type=bind,source=Aptfile,target=/tmp/Aptfile \
    DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
      $(grep -Ev '^\s*#' /tmp/Aptfile | xargs)

# Configure bundler
ENV LANG=C.UTF-8 \
  BUNDLE_JOBS=4 \
  BUNDLE_RETRY=3

# Store Bundler settings in the project's root
ENV BUNDLE_APP_CONFIG=.bundle

# Uncomment this line if you want to run binstubs without prefixing with `bin/` or `bundle exec`
# ENV PATH /app/bin:$PATH

# Upgrade RubyGems and install the latest Bundler version
RUN gem update --system && \
    gem install bundler

# Create a directory for the app code
RUN mkdir -p /app
WORKDIR /app

# Document that we're going to expose port 3000
EXPOSE 3000
# Use Bash as the default command
CMD ["/bin/bash"]
  TCODE
  ], trim_mode: "<>").result(binding)
file "#{DOCKER_DEV_ROOT}/compose.yml", ERB.new(
    *[
  <<~'TCODE'
x-app: &app
  build:
    context: .
    args:
      RUBY_VERSION: '<%= ruby_version %>'
<% if postgres_version %>
      PG_MAJOR: '<%= postgres_version.split('.', 2).first %>'
<% end %>
<% if node_version %>
      NODE_MAJOR: '<%= node_version.split('.', 2).first %>'
<% end %>
<% if yarn_version && yarn_version != 'latest' %>
      YARN_VERSION: '<%= yarn_version %>'
<% end %>
  image: <%= app_name %>-dev:1.0.0
  environment: &env
<% if node_version %>
    NODE_ENV: ${NODE_ENV:-development}
<% end %>
    RAILS_ENV: ${RAILS_ENV:-development}
  tmpfs:
    - /tmp
    - /app/tmp/pids

x-backend: &backend
  <<: *app
  stdin_open: true
  tty: true
  volumes:
    - ..:/app:cached
    - bundle:/usr/local/bundle
    - rails_cache:/app/tmp/cache
<% if File.directory?("app/assets") %>
    - assets:/app/public/assets
<% end %>
<% if node_version %>
    - node_modules:/app/node_modules
<% end %>
<% if gemspecs.key?("webpacker") %>
    - packs:/app/public/packs
    - packs-test:/app/public/packs-test
<% end %>
    - history:/usr/local/hist
<% if postgres_version %>
    - ./.psqlrc:/root/.psqlrc:ro
<% end %>
    - ./.bashrc:/root/.bashrc:ro
  environment: &backend_environment
    <<: *env
<% if redis_version %>
    REDIS_URL: redis://redis:6379/
<% end %>
<% if database_url %>
    DATABASE_URL: postgres://postgres:postgres@postgres:5432
<% end %>
<% if gemspecs.key?("webpacker") %>
    WEBPACKER_DEV_SERVER_HOST: webpacker
<% end %>
    MALLOC_ARENA_MAX: 2
    WEB_CONCURRENCY: ${WEB_CONCURRENCY:-1}
    BOOTSNAP_CACHE_DIR: /usr/local/bundle/_bootsnap
    XDG_DATA_HOME: /app/tmp/cache
<% if yarn_version %>
    YARN_CACHE_FOLDER: /app/node_modules/.yarn-cache
<% end %>
    HISTFILE: /usr/local/hist/.bash_history
<% if postgres_version %>
    PSQL_HISTFILE: /usr/local/hist/.psql_history
<% end %>
    IRB_HISTFILE: /usr/local/hist/.irb_history
    EDITOR: vi
<% if postgres_version || redis_version %>
  depends_on:
<% if postgres_version %>
    postgres:
      condition: service_healthy
<% end %>
<% if redis_version %>
    redis:
      condition: service_healthy
<% end %>
<% end %>

services:
  rails:
    <<: *backend
    command: bundle exec rails

  web:
    <<: *backend
    command: bundle exec rails server -b 0.0.0.0
    ports:
      - '3000:3000'
<% if gemspecs.key?("webpacker") || gemspecs.key?("sidekiq") %>
    depends_on:
<% if gemspecs.key?("webpacker") %>
      webpacker:
        condition: service_started
<% end %>
<% if gemspecs.key?("sidekiq") %>
      sidekiq:
        condition: service_started
<% end %>
<% end %>
<% if gemspecs.key?("sidekiq") %>

  sidekiq:
    <<: *backend
    command: bundle exec sidekiq
<% end %>
<% if postgres_version %>

  postgres:
    image: postgres:<%= postgres_version %>
    volumes:
      - .psqlrc:/root/.psqlrc:ro
      - postgres:/var/lib/postgresql/data
      - history:/usr/local/hist
    environment:
      PSQL_HISTFILE: /usr/local/hist/.psql_history
      POSTGRES_PASSWORD: postgres
    ports:
      - 5432
    healthcheck:
      test: pg_isready -U postgres -h 127.0.0.1
      interval: 5s
<% end %>
<% if redis_version %>

  redis:
    image: redis:<%= redis_version %>-alpine
    volumes:
      - redis:/data
    ports:
      - 6379
    healthcheck:
      test: redis-cli ping
      interval: 1s
      timeout: 3s
      retries: 30
<% end %>
<% if gemspecs.key?("webpacker") %>

  webpacker:
    <<: *app
    command: bundle exec ./bin/webpack-dev-server
    ports:
      - '3035:3035'
    volumes:
      - ..:/app:cached
      - bundle:/usr/local/bundle
      - node_modules:/app/node_modules
      - packs:/app/public/packs
      - packs-test:/app/public/packs-test
    environment:
      <<: *env
      WEBPACKER_DEV_SERVER_HOST: 0.0.0.0
      YARN_CACHE_FOLDER: /app/node_modules/.yarn-cache
<% end %>

volumes:
  bundle:
<% if node_version %>
  node_modules:
<% end %>
  history:
  rails_cache:
<% if postgres_version %>
  postgres:
<% end %>
<% if redis_version %>
  redis:
<% end %>
<% if File.directory?("app/assets") %>
  assets:
<% end %>
<% if gemspecs.key?("webpacker") %>
  packs:
  packs-test:
<% end %>

  TCODE
  ], trim_mode: "<>").result(binding)
file "dip.yml", ERB.new(
    *[
  <<~'TCODE'
version: '7.1'

# Define default environment variables to pass
# to Docker Compose
environment:
  RAILS_ENV: development

compose:
  files:
    - <%= DOCKER_DEV_ROOT %>/compose.yml
  project_name: <%= app_name %>

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: rails
    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: rails
    command: /bin/bash
    compose_run_options: [ no-deps ]

  # A shortcut to run Bundler commands
  bundle:
    description: Run Bundler commands
    service: rails
    command: bundle
    compose_run_options: [ no-deps ]
<% if gemspecs.key?("rspec") %>

  # A shortcut to run RSpec (which overrides the RAILS_ENV)
  rspec:
    description: Run RSpec commands
    service: rails
    environment:
      RAILS_ENV: test
    command: bundle exec rspec
<% end %>

  rails:
    description: Run Rails commands
    service: rails
    command: bundle exec rails
    subcommands:
      s:
        description: Run Rails server at http://localhost:3000
        service: web
        compose:
          run_options: [ service-ports, use-aliases ]
<% if !gemspecs.key?("rspec") %>
      test:
        description: Run unit tests
        service: rails
        command: bundle exec rails test
        environment:
          RAILS_ENV: test
<% end %>
<% if yarn_version %>

  yarn:
    description: Run Yarn commands
    service: rails
    command: yarn
    compose_run_options: [ no-deps ]
<% end %>
<% if postgres_version %>

  psql:
    description: Run Postgres psql console
    service: postgres
    default_args: <%= app_name %>_development
    command: psql -h postgres -U postgres
<% end %>
<% if redis_version %>

  'redis-cli':
    description: Run Redis console
    service: redis
    command: redis-cli -h redis
<% end %>

provision:
  # We need the `|| true` part because some docker-compose versions
  # cannot down a non-existent container without an error,
  # see https://github.com/docker/compose/issues/9426
  - dip compose down --volumes || true
<% if postgres_version %>
  - dip compose up -d postgres
<% end %>
<% if redis_version %>
  - dip compose up -d redis
<% end %>
  - dip bash -c bin/setup
  TCODE
  ], trim_mode: "<>").result(binding)

file "#{DOCKER_DEV_ROOT}/.bashrc", ERB.new(
    *[
  <<~'TCODE'
alias be="bundle exec"
  TCODE
  ], trim_mode: "<>").result(binding)
if postgres_version
  file "#{DOCKER_DEV_ROOT}/.psqlrc", ERB.new(
    *[
  <<~'TCODE'
-- Don't display the "helpful" message on startup.
\set QUIET 1

-- Allow specifying the path to history file via `PSQL_HISTFILE` env variable
-- (and fallback to the default $HOME/.psql_history otherwise)
\set HISTFILE `[ -z $PSQL_HISTFILE ] && echo $HOME/.psql_history || echo $PSQL_HISTFILE`

-- Show how long each query takes to execute
\timing

-- Use best available output format
\x auto

-- Verbose error reports
\set VERBOSITY verbose

-- If a command is run more than once in a row,
-- only store it once in the history
\set HISTCONTROL ignoredups
\set COMP_KEYWORD_CASE upper

-- By default, NULL displays as an empty space. Is it actually an empty
-- string, or is it null? This makes that distinction visible
\pset null '[NULL]'

\unset QUIET
  TCODE
  ], trim_mode: "<>").result(binding)
end

file "#{DOCKER_DEV_ROOT}/README.md", ERB.new(
    *[
  <<~'TCODE'
# Docker for Development

Source: [Ruby on Whales: Dockerizing Ruby and Rails development](https://evilmartians.com/chronicles/ruby-on-whales-docker-for-ruby-rails-development).

## Installation

- Docker installed.

For MacOS just use the [official app](https://docs.docker.com/engine/installation/mac/).

- [`dip`](https://github.com/bibendi/dip) installed.

You can install `dip` as Ruby gem:

```sh
gem install dip
```

## Provisioning

When using Dip it could be done with a single command:

```sh
dip provision
```

## Running

```sh
dip rails s
```

## Developing with Dip

### Useful commands

```sh
# run rails console
dip rails c

# run rails server with debugging capabilities (i.e., `debugger` would work)
dip rails s

# or run the while web app (with all the dependencies)
dip up web

# run migrations
dip rails db:migrate

# pass env variables into application
dip VERSION=20100905201547 rails db:migrate:down

# simply launch bash within app directory (with dependencies up)
dip runner

# execute an arbitrary command via Bash
dip bash -c 'ls -al tmp/cache'

# Additional commands

# update gems or packages
dip bundle install
<% if yarn_version %>
dip yarn install
<% end %>
<% if postgres_version %>

# run psql console
dip psql
<% end %>
<% if redis_version %>

# run Redis console
dip redis-cli
<% end %>

# run tests
<% if gemspecs.key?("rspec") %>
# TIP: `dip rspec` is already auto prefixed with `RAILS_ENV=test`
dip rspec spec/path/to/single/test.rb:23
<% else %>
# TIP: `dip rails test` is already auto prefixed with `RAILS_ENV=test`
dip rails test
<% end %>

# shutdown all containers
dip down
```

### Development flow

Another way is to run `dip <smth>` for every interaction. If you prefer this way and use ZSH, you can reduce the typing
by integrating `dip` into your session:

```sh
$ dip console | source /dev/stdin
# no `dip` prefix is required anymore!
$ rails c
Loading development environment (Rails 7.0.1)
pry>
```
  TCODE
  ], trim_mode: "<>").result(binding)

todos = [
  "📝  Important things to take care of:",
  "  - Make sure you have `ENV[\"RAILS_ENV\"] = \"test\"` (not `ENV[\"RAILS_ENV\"] ||= \"test\"`) in your test helper."
]

if database_url
  todos << "  - Don't forget to add `url: \<\%= ENV[\"DATABASE_URL\"] \%\>` to your database.yml"
end

if todos.any?
  say_status(:warn, todos.join("\n"))
end

say_status :info, "✅  You're ready to sail! Check out #{DOCKER_DEV_ROOT}/README.md or run `dip provision && dip up web` 🚀"
Comments

Sign up or Login to leave a comment.