cyberangles guide

Setting Up Ruby on Docker: A Complete Guide

Ruby is a dynamic, object-oriented programming language beloved for its simplicity and readability, powering frameworks like Rails, Sinatra, and Jekyll. However, managing Ruby environments across different machines—whether for development, testing, or production—can be challenging. Dependencies, gem versions, and system libraries often lead to the "it works on my machine" problem. **Docker** solves this by packaging applications and their dependencies into standardized units called containers. Containers ensure consistency across environments, simplify deployment, and isolate apps from the underlying system. In this guide, we’ll walk through setting up a Ruby environment with Docker, from basic development workflows to production-ready configurations. By the end, you’ll be able to build, run, and scale Ruby apps seamlessly with Docker.

Table of Contents

  1. Prerequisites
  2. Understanding Docker Basics
  3. Setting Up a Simple Ruby Project
  4. Creating a Dockerfile for Ruby
  5. Building and Running the Docker Image
  6. Development Workflow with Docker Compose
  7. Production-Ready Setup
  8. Troubleshooting Common Issues
  9. Conclusion
  10. References

Prerequisites

Before getting started, ensure you have the following installed:

  • Docker Desktop: Available for Windows, macOS, and Linux. Docker Desktop includes Docker Engine, Docker CLI, and Docker Compose.
  • Basic Command Line Knowledge: Familiarity with cd, ls, and git (optional but helpful).
  • Ruby Knowledge: Basic understanding of Ruby syntax and gems (Ruby’s package manager).

Understanding Docker Basics

If you’re new to Docker, let’s clarify a few key concepts:

  • Image: A read-only template containing instructions to create a container. For Ruby, we’ll use pre-built images from Docker Hub.
  • Container: A runnable instance of an image—like a lightweight, isolated “virtual machine” for your app.
  • Dockerfile: A text file with instructions to build a custom Docker image (e.g., installing gems, copying app code).
  • Docker Compose: A tool to define and run multi-container apps (e.g., a Ruby web app + PostgreSQL database).

Setting Up a Simple Ruby Project

Let’s start with a minimal Ruby application to demonstrate Docker setup. We’ll use Sinatra (a lightweight web framework) for simplicity, but the workflow applies to Rails, Rake tasks, or any Ruby app.

Step 1: Project Structure

Create a new directory for your project and navigate into it:

mkdir ruby-docker-demo && cd ruby-docker-demo

Your project will have these files:

ruby-docker-demo/
├── app.rb          # Ruby application code
├── Gemfile         # Gem dependencies
├── Gemfile.lock    # Locked gem versions (auto-generated)
├── Dockerfile      # Instructions to build the Docker image
└── docker-compose.yml  # (Optional) For multi-container setups

Step 2: Define Dependencies with Gemfile

Create a Gemfile to specify your app’s dependencies. For a Sinatra app:

# Gemfile
source "https://rubygems.org"

gem "sinatra"          # Web framework
gem "sinatra-reloader" # Auto-reload changes in development (optional)

Run bundle install locally to generate Gemfile.lock (this ensures Docker uses the exact gem versions):

bundle install

Note: You don’t need Ruby installed locally if you skip this step, but generating Gemfile.lock ensures reproducible builds.

Step 3: Write the Ruby App (app.rb)

Create a simple Sinatra app in app.rb:

# app.rb
require "sinatra"
require "sinatra/reloader" if development? # Auto-reload in development

get "/" do
  "Hello, Ruby on Docker! 🐳"
end

# Run the app on port 4567 (Sinatra's default)
set :port, 4567
set :bind, "0.0.0.0" # Allow external access (required for Docker)

This app responds to GET / with a greeting and runs on port 4567.

Creating a Dockerfile for Ruby

A Dockerfile tells Docker how to build your image. Let’s break it down step by step.

Step 1: Choose a Ruby Base Image

Start with an official Ruby image from Docker Hub. Use tags to specify the Ruby version and image variant:

  • Full Tag: ruby:3.2.2 (includes Ruby, system libraries, and build tools).
  • Slim Tag: ruby:3.2.2-slim (smaller, removes non-essential tools).
  • Alpine Tag: ruby:3.2.2-alpine (smallest, uses Alpine Linux; avoid if gems require native extensions).

For most apps, slim balances size and compatibility. Update your Dockerfile:

# Dockerfile
# Use the official Ruby 3.2.2 slim image as the base
FROM ruby:3.2.2-slim

Step 2: Set a Working Directory

Define a working directory inside the container where your app code will live:

# Set the working directory in the container
WORKDIR /app

Step 3: Copy Gems and Install Dependencies

Copy Gemfile and Gemfile.lock into the container first (this leverages Docker’s caching: if gems don’t change, Docker reuses the cached layer instead of re-installing gems every time you modify app.rb).

Add these lines to the Dockerfile:

# Copy Gemfiles to the container
COPY Gemfile Gemfile.lock ./

# Install system dependencies (if needed)
# Some gems require system libraries (e.g., pg for PostgreSQL needs libpq-dev)
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \ # Required to compile native gem extensions (e.g., nokogiri)
  && rm -rf /var/lib/apt/lists/* # Clean up to reduce image size

# Install gems
RUN bundle install --without development test # Skip dev/test gems in production

Why --without development test? In production, you won’t need gems like rspec or pry. Omit this flag in development.

Step 4: Copy Application Code

Copy the rest of your app code into the container:

# Copy the entire project into the container's working directory
COPY . .

Step 5: Expose Ports and Define the Startup Command

Expose the port your app runs on (Sinatra uses 4567 by default) and set the command to start the app:

# Expose port 4567 to the host machine
EXPOSE 4567

# Command to run the app
CMD ["ruby", "app.rb"]

Final Dockerfile

Putting it all together:

# Use an official Ruby runtime as the base image
FROM ruby:3.2.2-slim

# Set the working directory in the container
WORKDIR /app

# Install system dependencies (for native gem extensions)
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
  && rm -rf /var/lib/apt/lists/*

# Copy Gemfiles and install gems
COPY Gemfile Gemfile.lock ./
RUN bundle install --without development test

# Copy the entire project into the container
COPY . .

# Expose the port the app runs on
EXPOSE 4567

# Start the application
CMD ["ruby", "app.rb"]

Building and Running the Docker Image

Now that your Dockerfile is ready, let’s build and run the image.

Step 1: Build the Docker Image

Use docker build to create an image from your Dockerfile. Tag it with a name (e.g., ruby-docker-demo):

docker build -t ruby-docker-demo .
  • -t ruby-docker-demo: Tags the image for easy reference.
  • .: Uses the current directory as the “build context” (Docker needs access to Gemfile, app.rb, etc.).

The first build may take a few minutes as Docker downloads the base image and installs dependencies. Subsequent builds will be faster thanks to Docker’s layer caching.

Step 2: Run the Container

Start a container from your image with docker run. Map the container’s port 4567 to your host machine’s port 4567:

docker run -p 4567:4567 ruby-docker-demo
  • -p 4567:4567: Maps port 4567 on your host to port 4567 in the container.
  • ruby-docker-demo: The name of the image to run.

Visit http://localhost:4567 in your browser—you’ll see:
Hello, Ruby on Docker! 🐳

Bonus: Customize the Run Command

  • Detached Mode: Run the container in the background with -d:

    docker run -d -p 4567:4567 --name my-ruby-app ruby-docker-demo

    Stop it later with docker stop my-ruby-app.

  • View Logs: Use docker logs <container-name>:

    docker logs my-ruby-app
  • Environment Variables: Pass variables with -e:

    docker run -e "RACK_ENV=production" -p 4567:4567 ruby-docker-demo

Development Workflow with Docker Compose

For development, you’ll want to:

  • Auto-reload code changes without rebuilding the image.
  • Run additional services (e.g., PostgreSQL, Redis).

Docker Compose simplifies this with a docker-compose.yml file.

Step 1: Create docker-compose.yml

Add this file to your project:

# docker-compose.yml
version: "3.8"

services:
  web:
    build: . # Build from the current directory's Dockerfile
    ports:
      - "4567:4567" # Map host port 4567 to container port 4567
    volumes:
      - .:/app # Mount current directory into the container (auto-reload changes)
      - bundle_data:/usr/local/bundle # Cache gems to avoid re-installing
    environment:
      - RACK_ENV=development # Tell Sinatra to use development mode
    command: ruby app.rb # Override the Dockerfile's CMD (optional)

  # Optional: Add a PostgreSQL database
  db:
    image: postgres:15 # Use PostgreSQL 15
    environment:
      - POSTGRES_USER=myuser
      - POSTGRES_PASSWORD=mypassword
      - POSTGRES_DB=mydb
    volumes:
      - postgres_data:/var/lib/postgresql/data # Persist database data

volumes:
  bundle_data: # Named volume to cache gems
  postgres_data: # Named volume to persist PostgreSQL data

Key Features Explained:

  • volumes: .:/app: Mounts your local project directory into the container. When you edit app.rb, the change is instantly reflected in the container (no rebuild needed!).
  • bundle_data Volume: Caches gems in a Docker-managed volume, so bundle install only runs when Gemfile changes.
  • db Service: Adds a PostgreSQL container with persistent data (thanks to postgres_data volume).

Step 2: Start the App with Docker Compose

Run all services (web + db) with:

docker-compose up
  • Add -d to run in detached mode: docker-compose up -d.
  • Rebuild the image (e.g., after changing Dockerfile): docker-compose up --build.
  • Stop services: docker-compose down (add -v to delete volumes, e.g., docker-compose down -v).

Testing Auto-Reload

Edit app.rb (e.g., change the greeting to “Hello, Docker Compose! 🚢”) and refresh http://localhost:4567—the change will appear immediately (thanks to sinatra-reloader and the volume mount).

Production-Ready Setup

For production, optimize your Docker image for size, security, and reliability.

1. Multi-Stage Builds

Reduce image size by using a “builder” stage to compile gems, then copy only what’s needed to a smaller image.

Example Dockerfile for production:

# Stage 1: Build (install gems and compile native extensions)
FROM ruby:3.2.2-slim AS builder

WORKDIR /app

# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \ # For PostgreSQL gems (e.g., pg)
  && rm -rf /var/lib/apt/lists/*

# Copy gems and install
COPY Gemfile Gemfile.lock ./
RUN bundle install --without development test

# Copy app code
COPY . .

# Stage 2: Production (smaller image, no build tools)
FROM ruby:3.2.2-slim

WORKDIR /app

# Install runtime dependencies (only what’s needed to run the app)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq-dev \ # Keep if using PostgreSQL
  && rm -rf /var/lib/apt/lists/*

# Create a non-root user for security
RUN useradd -m appuser
USER appuser

# Copy gems and app code from the builder stage
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder /app /app

# Set production environment
ENV RACK_ENV=production

EXPOSE 4567

CMD ["ruby", "app.rb"]

Why this works: The final image excludes build tools like build-essential, reducing size by ~50%.

2. Use Non-Root Users

Running containers as root is a security risk. The example above creates a appuser and switches to it with USER appuser.

3. Environment Variables

Avoid hardcoding secrets (e.g., database URLs) in Dockerfile or docker-compose.yml. Use environment variables or tools like Docker Secrets (for Docker Swarm) or Kubernetes Secrets (for Kubernetes).

Troubleshooting Common Issues

1. Gems Fail to Install

  • Missing System Libraries: If a gem like pg (PostgreSQL) fails to compile, install its system dependency (e.g., libpq-dev in Debian/Ubuntu).
  • Corrupted Gemfile.lock: Delete Gemfile.lock and run bundle install locally, then rebuild the image.

2. Port Already in Use

If you see Bind for 0.0.0.0:4567 failed: port is already allocated, another app is using port 4567. Change the host port mapping:

docker run -p 4568:4567 ruby-docker-demo # Use host port 4568 instead

3. Changes Not Reflected in Container

If edits to app.rb don’t show up:

  • Ensure volumes: .:/app is in docker-compose.yml.
  • Restart the container: docker-compose restart web.

4. Database Connection Issues (e.g., with PostgreSQL)

  • Use the service name as the hostname (e.g., db for the PostgreSQL container in docker-compose.yml).
  • Check if the database is ready: Use depends_on with a healthcheck (Docker Compose v3+):
    db:
      image: postgres:15
      healthcheck:
        test: ["CMD-SHELL", "pg_isready -U myuser -d mydb"]
        interval: 5s
        timeout: 5s
        retries: 5
    web:
      depends_on:
        db:
          condition: service_healthy

Conclusion

Docker simplifies Ruby development and deployment by ensuring consistency across environments. With a Dockerfile, you can package your app and dependencies into a portable image, and docker-compose lets you run multi-service apps (like Ruby + PostgreSQL) with a single command.

By following this guide, you’ve learned to:

  • Build a Docker image for a Ruby app.
  • Run and debug containers locally.
  • Set up a development workflow with auto-reload.
  • Optimize images for production with multi-stage builds and security best practices.

Start using Docker for your next Ruby project to eliminate “works on my machine” headaches!

References