🚀 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?
- Install tons of version managers like rbenv, rvm, nvm, pyenv, asdf etc.
- Using that version manager install required for a project language version
- Install system dependecies like libyaml, gcc, make and etc using package managers like apt, yum, brew
- Try to run application meet yet another missing dependency like database library and goes back to step 3 again and again
- Spend hours to fix all the issues and finally run the application
How we handle it now?
- Install Docker
- Build the image
- 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.
A container image is a standardized package that includes all of the files, binaries, libraries, and configurations to run a container.
Docker docs
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
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
-
Development
for run development application and tests.
Line:
-
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
-
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
-
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
-
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
-
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
Production stage
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.
-
Anchor for re-using code in yaml file.
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.x-app: &app
-
build:
Section used bydocker 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 sectionservices: app: build: dockerfile: Dockerfile.other
And nowdocker 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 bytouch .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 ownPORT=4000
environment varible at .env file.
AlsoPORT
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.
No need to remember or open compose.yml file to remeber on which port application is running.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
-
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 configuringhealthcheck:
section. -
healthcheck
Here we define how docker will ensure that containers are healthy. Healthy mean that command athealthcheck: 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
At volume section
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:- ./:/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.
-
-
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
-
sidekiq
service that run sidekiq jobs processing. -
Redis
Run Redis container. That we use for sidekiq jobs processing and cache.
-
Postgres
Run a PostgreSQL database container. It uses a specific image version and maintains persistent data through a postgres named volume.
-
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?
-
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 rundocker compose up -d
in deattached (background) mode then rundocker compose -f logs
see logs of all services or passing service name to look logs for specific containerdocker 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