I recently worked on rewriting a super old Rails app (Rails 2 + Ruby 1.8.7). We felt a bit burned by the fact that we couldn’t even install such an old version of Ruby to run the old app. So, we decided to start fresh and prioritize setting up a dockerized development environment. Here, I’ll walk through the steps we followed to set up a pretty nice-to-work-with Rails development environment using Docker and VS Code Dev Containers.
1. Adding an Initial Dockerfile + Compose File
For this example, let’s say that we are working in the shiny-project
repo.
This is the initial Dockerfile
we create. Since we are creating a new Rails project, all we have to do at this point is specify that we want Ruby for our base image.
FROM ruby:3.3.0-bookworm
RUN mkdir -p /shiny-project
WORKDIR /shiny-project
EXPOSE 3000
And here is the compose.yml
file. We are mounting the entire shiny-project
directory to sync any changes to the repo between our host machine and the app container. Your database service + volume will also be defined in this same compose file.
version: '3'
services:
app:
build:
context: '.'
dockerfile: 'docker/Dockerfile'
stdin_open: true
tty: true
entrypoint: /shiny-project/docker/script/entry.sh
volumes:
- .:/shiny-project
ports:
- '3000:3000'
Here are the contents of the entry.sh
script called out as the app
service’s entrypoint. shiny-app
is what we will eventually call the rails app we create. This script just keeps the container alive indefinitely, listening for any commands to run, and able to respond to any kill signals we send.
#!/bin/bash
mkdir -p /shiny-app/tmp
mount -t tmpfs tmpfs /shiny-app/tmp
_term() {
echo "Caught term, send kill"
kill -SIGTERM "$child" 2>/dev/null
}
_int() {
echo "Caught int, send kill"
kill -SIGINT "$child" 2>/dev/null
}
trap _term TERM
trap _int INT
echo 'APP READY!'
sleep infinity &
child=$!
wait $child
exit 0
Now, we can run docker-compose up -d
to start our services.
2. Creating the Rails App
We need to exec into the app
container to create our new Rails app,
To install rails in the container:
docker-compose exec app gem install rails
To generate a new Rails app:
docker-compose exec app rails new shiny-app
3. Improving the Dockerfile
Now that we actually have a Rails app generated, we can take another pass at improving the Dockerfile
. We set the working directory to that of the Rails app to make running Rails CLI commands easier. And copy the Gemfile
and Gemfile.lock
into the image so that we don’t have to install Gems manually.
FROM ruby:3.3.0-bookworm
RUN mkdir -p /shiny-project/shiny-app
WORKDIR /shiny-project/shiny-app
ADD ./shiny-app/Gemfile /shiny-project/shiny-app/Gemfile
ADD ./shiny-app/Gemfile.lock /shiny-project/shiny-app/Gemfile.lock
RUN gem install bundler:2.5.3
RUN bundle install
EXPOSE 3000
4. Adding Convenience Scripts
Next, we can add a handful of scripts to make it easier to run Rails commands from our host machine’s terminal. We added the following four scripts to the shiny-project/bin
directory.
dx – shorthand for us to exec into the app
container.
#!/bin/bash
docker-compose exec app "$@"
drails – shorthand for running Rails CLI commands.
#!/bin/bash
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
$SCRIPT_DIR/dx bundle exec rails "$@"
drake – shorthand for running Rake tasks.
#!/bin/bash
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
$SCRIPT_DIR/dx bundle exec rake "$@"
dserver – shorthand to run the Rails server.
#!/bin/bash
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
$SCRIPT_DIR/dx bundle exec rails s -b 0.0.0.0 "$@"
With Direnv installed and PATH_add ./bin
added to the .envrc
, the following two commands are equivalent:
docker-compose exec app bundle install
dx bundle install
5. Creating a VS Code Dev Container
Next, we will set up a VS Code Dev Container definition to allow us to optimize our IDE for Rails development more easily. Add a new file, .devcontainers/devcontainer.json
, that contains the following:
```json
{
"name": "Existing Docker Compose (Extend)",
"dockerComposeFile": ["../compose.yml"],
"service": "app",
"workspaceFolder": "/shiny-project",
"customizations": {
"vscode": {
"extensions": [
"Shopify.ruby-extensions-pack",
"Hridoy.rails-snippets",
"aliariff.vscode-erb-beautify"
]
}
}
}
We are just making use of our existing compose.yml
app service, which already handles mounting project files and running a command to keep itself alive. We also call out a handful of VS Code extensions for Rails development that the Dev Container should install automatically. If any of these extensions require globally installed gems, we can install those as an
Then, with the Dev Containers extension installed and our app container already up and running, select the “Dev Containers: Reopen in Container” command.
6. Configuring the VS Code Workspace
You will have noticed that we have our Rails app nested inside of the top-level shiny-project
folder. We chose this directory structure because we didn’t want to junk up our Rails production image with the random scripts, doc, etc. at the top of the repo.
But, many extensions we added don’t like this organization and really want the Rails app up at the root. So to get the best possible editor support, the final thing we add is a shiny-project.code-workspace
to help handle this:
{
"folders": [
{
"path": "../shiny-project"
},
// We needed to bring the shiny-app folder up to the root level so the Ruby LSP extension will work properly.
{
"path": "shiny-app"
}
]
}
With this setup, we can easily run any of the Rails CLI commands from our terminals and have a highly customized IDE configuration. Plus, we are doing this all without ever having to install Ruby or any global Ruby gems on our own dev machines. And, our normal VS Code configuration does not have to get junked up with all the Ruby/Rails extensions and settings. Most of all, we hope this setup will help new developers hit the ground running when they pick it back up again in the years to come.