Setting Up Rails with Dev Containers

Setting Up Rails with Dev Containers

Having reproducible development environments is one of the best ways to guarantee ease of application setup and code sharing in teams. Dev containers are a way to achieve this.

In this article we’ll try to give you a small introduction to what dev containers are at their core and provide a minimal example on how to set up a Rails application to use dev containers.

For new applications using Rails 8+, the app can be generated for you with a minimal setup. For detailed instructions, you can check out their official guide opens a new window on this, so our guide won’t be covering the case of new rails apps. We’re more interested in tackling the problem of when you already have an application that has been around for some time (or a very long time) and you want to get it properly containerized.

What are dev containers?

The first thing we need to clarify, however, are what dev containers actually are. Because VS Code is so prevalent and it has a great extension ecosystem, it may seem that this is something specific to VS Code and their Dev Containers extension. It isn’t.

At it’s root, dev containers are just docker containers, with their normal Dockerfiles or even docker-compose.yml. In fact, if you already have a dockerized application, whatever image or set of images that might be generated from this configuration and that are used for development, might as well be your dev container.

Things get interesting when we start following certain conventions that have been adopted so that editor and IDE integration is made easy and, in turn, using these containers is also very easy.

What’s in a folder?

The first convention is the use of the .devcontainer folder. Here is where you’re going to store your Dockerfile and your docker-compose.yml, if you use one.

Using this folder makes it easy for editor extensions related to dev containers to pick up your configurations.

The devcontainer.json

The second convention is the use of this json file to describe your application’s setup. A minimal example would be:

{
  "name": "Barebones Dev Container",
  "build": {
    "dockerfile": "Dockerfile",
    "context": ".."
  },
  "postCreateCommand": "echo 'Container is ready'"
}

Now just run it

This is all that’s needed, actually. With your Dockerfile and docker-compose.yml in place, if you need one, you can build and run your dev container. The json file will just make it so if you’re using VS Code or some other editor that has an extension for dev containers, the editor will know to do the following:

  1. Build your containers: docker build -f .devcontainer/Dockerfile -t my-dev-container .
  2. Run them: docker run -it --name my-dev-container-instance -v "$(pwd):/workspace" my-dev-container

And that’s it. A barebones example and explanation

Ok, but how’s it done with Rails?

Given the above, setting up a Rails application to use dev containers is straightforward. Like before we create a .devcontainer folder and place our Dockerfile and docker-compose.yml inside. In our case we need a docker-compose.yml because, besides the application, we use a database. Most Rails apps should need this and maybe even more services for background jobs, key value data stores and so forth. Furthermore, we need to setup our database and bundle our gems, which means we also need and entrypoint.sh. All of this can go inside the .devcontainer folder:

# .devcontainer/Dockerfile
FROM ruby:3.3.0

# Install essential packages, PostgreSQL client libraries, and netcat
RUN apt-get update && apt-get install -y \
    nodejs \
    yarn \
    build-essential \
    libpq-dev \
    netcat-traditional \
 && rm -rf /var/lib/apt/lists/*

# Install Rails and Bundler
RUN gem install rails bundler

# Set working directory
WORKDIR /app_name # Call the workspace dir whatever your application name is

# Copy the entrypoint script and make it executable
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

# Set the entrypoint (no CMD here to auto-run Rails)
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
#!/bin/bash
set -e

echo "Running bundle install..."
bundle install

echo "Waiting for PostgreSQL to be ready at ${DATABASE_HOST:-db}:5432..."
# Wait until the PostgreSQL service is accepting connections
while ! nc -z ${DATABASE_HOST:-db} 5432; do
  sleep 1
done

echo "PostgreSQL is ready. Setting up the Rails database..."
bundle exec rails db:create
bundle exec rails db:migrate

echo "Rails database setup complete."

echo "Rails database setup complete. Launching interactive shell..."
exec bash
# docker-compose.yml
version: '3.8'
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ..:/app_name:cached # Make our project's root a mounted volume
      - bundle_cache:/store/vendor/bundle # Cache our gems in a named volume so we don't build everything from scratch every time.
    ports:
      - "3000:3000"
    depends_on:
      - db
    environment:
      - DATABASE_HOST=db
      - DATABASE_USERNAME=postgres
      - DATABASE_PASSWORD=postgres
      - DATABASE_NAME=rails_dev
      - BUNDLE_PATH=/app_name/vendor/bundle # Properly configure BUNDLE_PATH in the container.
  db:
    image: postgres:13
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: rails_dev
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:
  bundle_cache:

And, finally our json file. Again, this is good to have even if you run your containers manually, for documentation. Obviously this is needed to use VS Code’s Dev-Containers extension:

// .devcontainer/devcontainer.json
{
  "name": "Rails Dev Container",
  "dockerComposeFile": "docker-compose.yml",
  "service": "web",
  "workspaceFolder": "/app_name",
  "customizations": {
    "vscode": {
      "extensions": [
        "rebornix.Ruby"  // Optional: Provides Ruby language support in VS Code
      ]
    }
  },
  "postCreateCommand": "bundle install && rails db:create && rails db:migrate"
}

Now you can build and run your containers:

docker compose -f .devcontainer/docker-compose.yml build
docker compose -f .devcontainer/docker-compose.yml run --rm --service-ports web

And that’s all you need! Finally, if you open the project’s folder in VS Code and the Dev Containers extension is installed, you can open the project in the container and it will all work just the same.

To access your app, you can just visit localhost:3000. To run tests, if you’re not using VS Code, would be with docker compose. Below we use rspec, but you can replace it with whatever command you use to run tests. Furthermore, you need to use bundle exec unless you configure your container to add the gem executables bundler installs to the path. We decided not to just for simplicity:

docker compose -f .devcontainer/docker-compose.yml run --rm --service-ports web bundle exec rspec

Final Considerations

One may argue that there isn’t much benefit in running dev containers manually, given the smoother approach VS Code offers. No to mention that if you let VS Code set up the dev containers for you, you’ll notice it makes heavy use of the devcontainer.json file to do some of the things we did in entrypoint.sh.

However, we just wanted to show that this is a setup that can work for all editors of all team members as long as all team members have docker installed. Furthermore, the experience can be made smoother by using tools to automate more complex commands like the docker compose ones we used. We were recently introduced to dip opens a new window which would allow you to say, for example, dip bash and it’ll just give you the shell into the application container as if you had run the docker compose run command presented here. All of this with a simple yml file.

Here at FastRuby.io we specialize in making your apps better to develop, faster to run and easier to maintain. Got an application in need of some maintenance love? Talk to us! opens a new window

Get the book