🚀 Ruby on Rails development using Docker 🐳

Source code docker and compose files for this article is available at github.com/frontandstart/initapp-rails

Article will be helpfull if you just beginning with Docker or may be help to improve your setup.

How we handle setup of Ruby or any other language project before?

  1. Install tons of version managers like rbenv, rvm, nvm, pyenv, asdf etc.
  2. Using that version manager install required for a project language version
  3. Install system dependecies like libyaml, gcc, make and etc using package managers like apt, yum, brew
  4. Try to run application meet yet another missing dependency like database library and goes back to step 3 again and again
  5. Spend hours to fix all the issues and finally run the application

How we handle it now?

  1. Install Docker
  2. Build the image
  3. Run the application

Is it that simple?

Yes, it is and of course it add layer of complexity but once you learn it or did it for your project - probablby you will never go back to the old way of handling project setup. Lets detailed overview how it works. You may need to know some basic concepts that Docker uses like image, container, volume.

Image:

A container image is a standardized package that includes all of the files, binaries, libraries, and configurations to run a container.

Docker docs
Container:

Simply put, containers are isolated processes for each of your app's components. Each component - the frontend React app, the Python API engine, and the database - runs in its own isolated environment, completely isolated from everything else on your machine.

Docker docs
Volume:

Basically volume is named folder at you host machine that is can be mounted to the container file system. It is used to persist data generated by and used by Docker containers.

Docker docs

Docker development enviroment requires Dockerfile and compose.yaml files. Lets go through Dockerfile first.

Dockerfile is a list of instructions for docker to build an image. Our Dockerfile maden from own exprience and partly inspired by:

  • At left side you will see description of file and content of file at right side.
  • We try making it simple as it possible, lets take a look. As you can see our Dockerfile splitted into two stages:

    • Development for run development application and tests. Line:
      FROM ruby:${RUBY_VERSION}-slim-bullseye AS development
    • Production for builiding production image that will be used at CI and deploying to server Line:
      FROM development AS production

  1. Pick your ruby version and distributive at docker hub and use it as base image for development stage.
    ARG RUBY_VERSION=3.4.2
    FROM ruby:${RUBY_VERSION}-slim-bullseye AS development
  2. Ruby on Rails is dependend from nodejs and yarn or npm for assets compilation.
    Instead of installing them from system package manager we use already compiled from corresponding distributive docker image.
    ARG NODE_VERSION=22.14.0
    ARG YARN_VERSION=1.22.22
    
    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 
    
    ENV PATH=/usr/local/node/bin:$PATH
    
    RUN npm install -g yarn@${YARN_VERSION} && \
        rm -rf /tmp/node-build-master
  3. Install ruby on rails and project system dependencies like libyaml, gcc, make, etc using system package manager. Here is also listed some optional dependencies like libvips for image processing, graphviz for erd diagrams generation and etc that we use at initapp-rails project.
    RUN apt-get update -y && apt-get install -y \
      g++ \
      make \
      gcc \
      libpq-dev \
      libffi-dev \
      libyaml-dev \
      libxml2-dev \
      libxslt-dev \
      zlib1g-dev \
      xz-utils \
      libvips \
      git \
      curl \
      libvips \
      graphviz \
      bash
  4. Set BUNDLE_JOBS with amoun of cpu of you local machine for parallel bunndle install. And pre-install bundler and foreman gems at development image.
    ENV BUNDLE_JOBS=$(nproc)
    
    RUN gem install bundler foreman
  5. Production stage
  6. Copy source code from host into production image, install without development and test gems and precompile assets.
    FROM development AS production
    
    COPY . .
    
    RUN bundle config set production true
    RUN bundle install --without development test \
                      --jobs $(nproc --all) \
                      --clean \
                      --deployment
    RUN yarn install
    RUN RAILS_ENV=production SECRET_KEY_BASE=dummy bundle exec rails assets:precompile
ARG RUBY_VERSION=3.4.2
FROM ruby:${RUBY_VERSION}-slim-bullseye AS development

RUN apt-get update -y && apt-get install -y \
      g++ \
      make \
      gcc \
      libpq-dev \
      libffi-dev \
      libyaml-dev \
      libxml2-dev \
      libxslt-dev \
      zlib1g-dev \
      xz-utils \
      libvips \
      git \
      libvips \
      curl \
      graphviz \
      bash

ARG NODE_VERSION=22.14.0
ARG YARN_VERSION=1.22.22

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 

ENV PATH=/usr/local/node/bin:$PATH

RUN npm install -g yarn@${YARN_VERSION} && \
    rm -rf /tmp/node-build-master

ENV BUNDLE_JOBS=$(nproc)

RUN gem install bundler foreman

FROM development AS production

COPY . .

RUN bundle config set production true
RUN bundle install --without development test \
                  --jobs $(nproc --all) \
                  --clean \
                  --deployment
RUN yarn install
RUN RAILS_ENV=production SECRET_KEY_BASE=dummy bundle exec rails assets:precompile

Dockerfile summary

Rebuild development image only in case of changing ruby version, distributive or added new system dependency. We did not copy source code and not install gems, nodejs modules becouse we do that in mounted volumes to share them beetween container runs.

Let's explore compose.yaml file

`docker compose` docker subcommand previously was known as `docker-compose`. By default will search for `compose.yaml` file in current directory and then for older `docker-compose.yml` file name version. File may contains six main top-level keys. We will focus only on services and volumes

  • name - name of the application, will be used as a prefix name of container, image and volume.
  • services - list of services (containers) that will be run
  • volumes - list of volumes that will be used by services
  • networks - list of networks that will be used by services
  • secrets - list of secrets that will be used by services
  • configs - list of configs that will be used by services

Services

We have using 5 services app, sidekiq, redis, postgres and mailcatcher. Sidekiq and redis also might be optional for your project.

  1. Anchor for re-using code in yaml file.
    x-app: &app
    This shared code will used below at app and sidekiq services. Here we set development image name, build context, target stage at Dockerfile and set development environment variables. Also set local .env file as storage for your own development secrets.
    • build:
      Section used by docker compose build app command to build our image and tag is by name that read from image key initapp-rails in out case. Last app is argument witch is name of our service. So it will same as command: docker build -t initapp-rails --target development . Where . is context (current directory), target is development stage at Dockerfile and initapp-rails is name of our generated image.
      Docker commannd use Dockerfile from working directory by default. Specify another file by simply adding dockerfile to build section services: app: build: dockerfile: Dockerfile.other
      And now docker compose build app will be equal to the: docker build -t initapp-rails --target development -f Dockerfile.other .

    • env_file:
      List of file with environment varibles that will be passed to the container at runtime. Gitignored .env file where your can store own secrets required for development or debugging third-party integrations. Ensure that file exists before application starts by touch .env

    • stdin_open: true
      For debugging application usign debugger gem, read how at readme section.

    • ports:
      Here is a list of ports that will be exposed from container to your host machine in format:- host_port:container_port. We use ${PORT:-3000} for host and container - read it as using PORT environment varible if it is set or 3000 by default.
      We solve two things with this code:
      You able to run few projects at the same time by setting own PORT=4000 environment varible at .env file.
      Also PORT is used by default by puma or foreman as a port where application running and you will see nice output with a container port at console that will match with host.

      app-1  | Puma starting in single mode...
      app-1  | * Puma version: 6.4.2 (ruby 3.2.5-p208) ("The Eagle of Durango")
      app-1  | *  Min threads: 5
      app-1  | *  Max threads: 5
      app-1  | *  Environment: development
      app-1  | *          PID: 1
      app-1  | * Listening on http://0.0.0.0:4000
      app-1  | Use Ctrl-C to stop
      No need to remember or open compose.yml file to remeber on which port application is running.

    • tmpfs
      This list of folders will be mounted as temporary volume. It is used to store temporary files that are not persisted between container restarts. Every restart of puma there will be generated new pidfile.

    • depends_on
      Here we define that our app container depends on postgres and redis container. It means that app container will be started after postgres and redis containers. condition: service_healthy Ensure that postgres and redis containers are healthy before starting app container. You can define how to check that container is healthy by configuring healthcheck: section.

    • healthcheck
      Here we define how docker will ensure that containers are healthy. Healthy mean that command at healthcheck: test: ["CMD", "redis-cli", "ping"] block will exited and exited with 0 (success) status code. And self-explaningable fields to set healtcheck interval, timeout and retries.

    • volumes
      Here we define list of volumes that will be mounted to the container. It can be some relative path from you project root directory like:

      At volume section - ./:/app:c mounts current directory to the container at /app folder.
      - cache:/app/tmp/cache:d mounts cache volume to the container at /app/tmp/cache folder.
      - bundle:/usr/local/bundle:d mounts bundle volume to the container at /usr/local/bundle folder.
      - node_modules:/app/node_modules:d mounts node_modules volume to the container at /app/node_modules folder.
      Fifth line: - data:/data:d mounts data volume to the container at /data folder. This needed for sharing gems, nodejs modules and data between containers and containers restarts.

      You need to define named volume at volumes: section.

  2. app

    Service that runs Ruby on Rails application container using Procfile.dev.

    At command section we define the command that will be executed inside the app container when docker compose starts: bin/dev --auto-deps-install --auto-migrate

    This script performs the following tasks:

    • Ensures Ruby gems and Node packages are installed
    • Creates database on first run
    • Applies Rails migrations
    • Runs processes defined in Procfile.dev including:
      • Puma web server
      • CSS compilation
      • JavaScript compilation
    content

  3. sidekiq
    service that run sidekiq jobs processing.
  4. Redis

    Run Redis container. That we use for sidekiq jobs processing and cache.

  5. Postgres

    Run a PostgreSQL database container. It uses a specific image version and maintains persistent data through a postgres named volume.

  6. Mailcatcher

    Run a Mailcatcher container. It is optional and used at initapp-rails project for local email sending testing.

Volumes

Volumes are used to persist data between containers restarts. Once you define it docker will create it following ${name_section_or_root_folder_name}_${volume_name} convention. For example in our project for data volume complete name will be initapp-rails_data. Run docker volume inspect initapp-rails_data to see volume details and exact location at your machine.

name: initapp

x-app: &app
  image: initapp:development
  build:
    context: .
    target: development
  environment:
    REDIS_URL: redis://redis:6379/0
    RAILS_ENV: development
    RACK_ENV: development
    DATABASE_URL: postgres://postgres:postgres@postgres:5432
    RAILS_LOG_TO_STDOUT: true
    SECRET_KEY_BASE: secret
    JWT_SECRET_KEY: secret
    HISTFILE: /data/.bash_history
    IRB_HISTORY_FILE: tmp/.irb_history
    MAILCATCHER_HOST: mailcatcher
  env_file:
    - .env
  volumes:
    - ./:/app:c
    - cache:/app/tmp/cache:d
    - bundle:/usr/local/bundle:d
    - node_modules:/app/node_modules:d
    - data:/data:d
  stdin_open: true
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

services:
  app:
    <<: *app
    ports:
      - ${PORT:-3000}:${PORT:-3000}
    healthcheck:
      test: ["CMD", "curl", "http://app:3000/health"]
      interval: 20s
      timeout: 5s
      retries: 3
      start_period: 20s
    command: bin/dev --auto-deps-install --auto-migrate

  sidekiq:
    <<: *app
    command: bundle exec sidekiq -C config/sidekiq.yml

  redis:
    image: redis:7.4.2
    volumes:
      - redis:/data:delegated
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 1s
      timeout: 3s
      retries: 30
    entrypoint: redis-server --appendonly yes
    restart: always

  postgres:
    image: postgres:16.8-bookworm
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_HOST: 0.0.0.0
    volumes:
      - postgres:/var/lib/postgresql/data:c
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h postgres -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
    
  mailcatcher:
    image: dockage/mailcatcher
    restart: on-failure:10
    ports:
      - ${MAILCATCHER_PORT:-1080}:1080

volumes:
  postgres:
  redis:
  bundle:
  node_modules:
  data:
  cache:

How to run everything?

  1. Create local .env file:
    touch .env

    Run application:
    docker compose up

    At first run docker compose will build development image for app and sidekiq containers using build section. Internally it will run command like this: docker build --tag initapp:development --target development .
    Where development is target stage at Dockerfile, dot is current directory and initapp:development is tag of generated image.

    Docker compose looking locally for images tagged same as image: section of compose file. Once failed it will try pull it from docker hub container registry. For our case it will pull postgres:16.4-bullseye and redis:7.4.0 images and use already builded initapp-rails image run all containers for all services: app, sidekiq, redis and postgres mounting volumes, expose ports from containers into you computer.

    Terminal will be attached to the containers STDOUT and logs will be printed, by clicking CTRL+C command you will stop containers.
    You can run docker compose up -d in deattached (background) mode then run docker compose -f logs see logs of all services or passing service name to look logs for specific container docker compose -f logs app

    Also run specific container and command by: docker compose run app bash this will run new app container with bash session in it.
    Or you may run specific rails command and exit: docker compose run app rails routes

    Once you up containers from another terminal window you able to exec (enter) into alredy running service like with docker compose exec app bash replace app wih sidekiq, redis or postgres to choise container that you want to enter.

    Read more at docker compose cli docs

    Older version of docker came with docker-compose utility written on python that nowdays rewrited on golang and moved to docker compose subcommand of docker cli. Docker compose read configuration from compose.yaml file and run all containers described at services section. You may use another file by passing -f option to docker compose command like: docker compose -f compose.prod.yml up

    Read more detailed at official docker compose file specification

Written by r3cha

Do you need help with a project?
Schedule a call with us and let's discuss it.