Rails #

Ruby on Rails is an opinionated, batteries-included web framework more than 20 years old. With its “Convention over Configuration” philosophy, Rails lets developers build complete web applications — models, views, controllers, ORM (ActiveRecord), migrations, asset pipeline, mailers, job queues, caching, and WebSocket — without choosing libraries one by one. Docker Compose complements Rails significantly because Rails setups are notorious for their many native dependencies (libpq, nodejs, yarn, imagemagick) that often conflict between developers.

This article covers a Docker Compose setup for Rails local development, from Ruby Dockerfiles, docker-compose with Postgres + Redis + Sidekiq, the asset pipeline, to best practices.

Prerequisites #

Make sure you have installed:

  • Docker and Docker Compose (latest versions)
  • Ruby 3.3+ (optional, for host-side development)
  • Node.js 20+ and Yarn (for assets)
  • Git

A standard Rails project structure (from rails new):

my-rails-app/
├── app/
│   ├── assets/
│   ├── channels/
│   ├── controllers/
│   ├── helpers/
│   ├── jobs/
│   ├── mailers/
│   ├── models/
│   └── views/
├── bin/
│   ├── rails
│   ├── rake
│   ├── setup
│   └── ...
├── config/
│   ├── application.rb
│   ├── boot.rb
│   ├── database.yml
│   ├── environments/
│   │   ├── development.rb
│   │   ├── production.rb
│   │   └── test.rb
│   ├── initializers/
│   ├── locales/
│   ├── puma.rb
│   ├── routes.rb
│   └── storage.yml
├── db/
│   ├── migrate/
│   ├── seeds.rb
│   └── schema.rb
├── lib/
├── log/
├── public/
├── storage/
├── test/ (or spec/)
├── tmp/
├── vendor/
├── Gemfile
├── Gemfile.lock
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
├── .env
└── .dockerignore

This structure comes from rails new myapp --database=postgresql --css=tailwind. For an API-only Rails app (no views), use rails new myapp --api.

A Dockerfile for Production #

# syntax=docker/dockerfile:1.6
FROM ruby:3.3-slim AS builder

WORKDIR /app

RUN apt-get update -qq && apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    libyaml-dev \
    nodejs \
    yarn \
    git \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Cache gems: copy the Gemfile first
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'production' \
    && bundle install --jobs 4

COPY . .

# Precompile assets for production
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile

# Second stage: a slim runtime
FROM ruby:3.3-slim

WORKDIR /app

RUN apt-get update -qq && apt-get install -y --no-install-recommends \
    libpq5 \
    nodejs \
    yarn \
    && rm -rf /var/lib/apt/lists/*

# Copy installed gems and the app
COPY --from=builder /app /app

# Non-root user
RUN addgroup -S rails && adduser -S rails -G rails
USER rails

EXPOSE 3000

CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

A slim production image with Puma. Assets are precompiled in the builder stage.

A Dockerfile for Development #

# Dockerfile.dev
FROM ruby:3.3-slim

WORKDIR /app

ENV BUNDLE_PATH=/usr/local/bundle \
    BUNDLE_JOBS=4 \
    RAILS_ENV=development

RUN apt-get update -qq && apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    libyaml-dev \
    nodejs \
    yarn \
    git \
    curl \
    vim \
    && rm -rf /var/lib/apt/lists/*

# Install bundler
RUN gem install bundler

# Cache gems
COPY Gemfile Gemfile.lock ./
RUN bundle install

# Source code is mounted via a volume

EXPOSE 3000

CMD ["bin/rails", "server", "-b", "0.0.0.0"]

BUNDLE_PATH=/usr/local/bundle ensures gems are installed into a persistent path (mountable as a volume for performance).

docker-compose.yml #

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: rails-app:dev
    container_name: rails-web
    command: bundle exec rails server -b 0.0.0.0 -p 3000
    volumes:
      - ./:/app
      - bundle:/usr/local/bundle
      - rails-tmp:/app/tmp
      - rails-log:/app/log
    ports:
      - "3000:3000"
    environment:
      - RAILS_ENV=development
      - DATABASE_URL=postgresql://rails:pass@db:5432/railsapp
      - REDIS_URL=redis://cache:6379/0
      - SECRET_KEY_BASE=local-dev-secret-key-change-me-at-least-64-chars-long-please-use-something-secure
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  worker:
    build:
      context: .
      dockerfile: Dockerfile.dev
    command: bundle exec sidekiq -C config/sidekiq.yml
    volumes:
      - ./:/app
      - bundle:/usr/local/bundle
    environment:
      - RAILS_ENV=development
      - DATABASE_URL=postgresql://rails:pass@db:5432/railsapp
      - REDIS_URL=redis://cache:6379/0
    depends_on:
      - cache
      - db
    profiles: ["with-worker"]

  db:
    image: postgres:16-alpine
    container_name: rails-db
    environment:
      - POSTGRES_USER=rails
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=railsapp
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U rails -d railsapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    container_name: rails-cache
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    volumes:
      - cache-data:/data
    ports:
      - "6379:6379"

  mailhog:
    image: mailhog/mailhog:latest
    container_name: rails-mailhog
    ports:
      - "1025:1025"
      - "8025:8025"

volumes:
  db-data:
  cache-data:
  bundle:
  rails-tmp:
  rails-log:

Service Explanations #

web — the main Rails service. Uses bin/rails server bound to 0.0.0.0 so it’s reachable from the host. Source code and the bundle volume (for gem caching) are mounted. The rails-tmp and rails-log volumes keep host data clean.

worker — a Sidekiq worker for background jobs. Enabled with --profile with-worker.

db — PostgreSQL for persistent data. The pg_isready healthcheck.

cache — Redis for caching, sessions, and the Sidekiq queue.

mailhog — a mock SMTP server for development. Send email to MailHog on port 1025, view it in the UI at http://localhost:8025.

Database Configuration #

config/database.yml:

default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  url: <%= ENV["DATABASE_URL"] %>
  host: <%= ENV.fetch("DB_HOST", "db") %>

development:
  <<: *default
  database: railsapp

test:
  <<: *default
  database: railsapp_test

production:
  <<: *default
  database: railsapp_production
  username: rails
  password: <%= ENV["RAILS_DATABASE_PASSWORD"] %>

Use DATABASE_URL from an environment variable, or individual host/username/password values. The postgresql://user:pass@host:port/db format is more portable.

Cache and Session Stores #

config/environments/development.rb:

Rails.application.configure do
  config.cache_store = :redis_cache_store, {
    url: ENV.fetch("REDIS_URL", "redis://cache:6379/0"),
    namespace: "rails:cache",
    expires_in: 1.day,
  }

  config.session_store :redis_store, {
    key: "_app_session",
    redis: { url: ENV.fetch("REDIS_URL", "redis://cache:6379/0") },
    expire_after: 7.days,
  }

  config.action_controller.perform_caching = true
  config.action_mailer.delivery_method = :smtp
  config.action_mailer.smtp_settings = {
    address: "mailhog",
    port: 1025,
  }
  config.action_mailer.raise_delivery_errors = false

  config.active_job.queue_adapter = :sidekiq
end

Sidekiq for Background Jobs #

Gemfile (add):

gem "sidekiq", "~> 7.0"
gem "redis", "~> 5.0"

config/sidekiq.yml:

:concurrency: 5
:queues:
  - default
  - mailers
  - active_storage
  - low

config/initializers/sidekiq.rb:

require "sidekiq"

redis_url = ENV.fetch("REDIS_URL", "redis://cache:6379/0")

Sidekiq.configure_server do |config|
  config.redis = { url: redis_url, network_timeout: 5 }
end

Sidekiq.configure_client do |config|
  config.redis = { url: redis_url, network_timeout: 5 }
end

Example job (app/jobs/send_welcome_email_job.rb):

class SendWelcomeEmailJob < ApplicationJob
  queue_as :mailers

  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
  end
end

ActiveRecord Models and Migrations #

Migration (db/migrate/20240101000000_create_users.rb):

class CreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :name, null: false
      t.string :password_digest, null: false
      t.boolean :is_verified, default: false
      t.timestamps
    end
    add_index :users, :email, unique: true
  end
end

Model (app/models/user.rb):

class User < ApplicationRecord
  has_secure_password

  has_many :posts, dependent: :destroy

  validates :email, presence: true, uniqueness: { case_sensitive: false },
            format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :name, presence: true, length: { minimum: 2, maximum: 100 }
  validates :password, length: { minimum: 8 }, if: -> { password.present? }

  scope :verified, -> { where(is_verified: true) }
  scope :recent, -> { order(created_at: :desc) }

  def as_json(options = {})
    super(options.reverse_merge(only: [:id, :email, :name, :is_verified, :created_at]))
  end
end

has_secure_password from Rails 7+ requires bcrypt in the Gemfile. It automatically validates passwords and provides the authenticate method.

Controllers and Routes #

Routes (config/routes.rb):

Rails.application.routes.draw do
  get "/health", to: "health#show"

  namespace :api do
    namespace :v1 do
      resources :users, only: [:index, :show, :create, :update, :destroy] do
        collection do
          get :me
        end
      end

      post "/auth/login", to: "auth#login"
      post "/auth/register", to: "auth#register"
      post "/auth/refresh", to: "auth#refresh"
    end
  end
end

Application Controller (app/controllers/application_controller.rb):

class ApplicationController < ActionController::API
  rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
  rescue_from ActionController::ParameterMissing, with: :parameter_missing
  rescue_from ActiveRecord::RecordInvalid, with: :record_invalid

  private

  def authenticate!
    header = request.headers["Authorization"]
    token = header&.split(" ")&.last
    payload = JwtService.decode(token) if token
    @current_user = User.find(payload["user_id"]) if payload
  rescue
    render json: { error: "unauthorized" }, status: :unauthorized
  end

  def current_user
    @current_user
  end

  def record_not_found(error)
    render json: { error: "not found" }, status: :not_found
  end

  def parameter_missing(error)
    render json: { error: error.message }, status: :unprocessable_entity
  end

  def record_invalid(error)
    render json: { error: error.record.errors.full_messages }, status: :unprocessable_entity
  end
end

Users Controller (app/controllers/api/v1/users_controller.rb):

module Api
  module V1
    class UsersController < ApplicationController
      before_action :authenticate!, except: [:create]
      before_action :set_user, only: [:show, :update, :destroy]

      def index
        page = (params[:page] || 1).to_i
        per_page = [(params[:per_page] || 20).to_i, 100].min
        users = User.recent.offset((page - 1) * per_page).limit(per_page)
        render json: {
          data: users.as_json,
          page: page,
          per_page: per_page,
        }
      end

      def show
        render json: @user.as_json
      end

      def create
        user = User.new(user_params)
        if user.save
          SendWelcomeEmailJob.perform_later(user.id)
          render json: user.as_json, status: :created
        else
          render json: { error: user.errors.full_messages }, status: :unprocessable_entity
        end
      end

      def update
        if @user.update(user_params)
          render json: @user.as_json
        else
          render json: { error: @user.errors.full_messages }, status: :unprocessable_entity
        end
      end

      def destroy
        @user.destroy
        head :no_content
      end

      def me
        render json: current_user.as_json
      end

      private

      def set_user
        @user = User.find(params[:id])
      end

      def user_params
        params.require(:user).permit(:email, :name, :password, :password_confirmation)
      end
    end
  end
end

Health Check #

app/controllers/health_controller.rb:

class HealthController < ApplicationController
  def show
    db_ok = ActiveRecord::Base.connection.execute("SELECT 1") ? true : false
    render json: {
      status: db_ok ? "ok" : "degraded",
      database: db_ok ? "up" : "down",
      time: Time.current.iso8601,
    }, status: db_ok ? :ok : :service_unavailable
  end
end

Access at http://localhost:3000/health. Returns 503 when the database is down.

Build and Run #

# Build the image
docker compose build

# Install gems (if the Gemfile changed)
docker compose run --rm web bundle install

# Create the database
docker compose run --rm web bin/rails db:create

# Run migrations
docker compose run --rm web bin/rails db:migrate

# Run all services
docker compose up -d

# Run with Sidekiq
docker compose --profile with-worker up -d

# View logs
docker compose logs -f web

# Rails console
docker compose exec web bin/rails console

# Generate a new migration
docker compose exec web bin/rails generate migration CreatePosts

# Reset the database
docker compose down -v
docker compose up -d
docker compose run --rm web bin/rails db:create db:migrate db:seed

Access:

  • App: http://localhost:3000
  • Health: http://localhost:3000/health
  • API: http://localhost:3000/api/v1/users
  • PostgreSQL: localhost:5432
  • Redis: localhost:6379
  • MailHog UI: http://localhost:8025

Test:

# Register
curl -X POST http://localhost:3000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"user":{"email":"[email protected]","name":"Adi","password":"password123","password_confirmation":"password123"}}'

# Login
curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"password123"}'

# Get the current user
curl http://localhost:3000/api/v1/users/me \
  -H "Authorization: Bearer your-token-here"

Testing with RSpec #

Gemfile (group :development, :test):

group :development, :test do
  gem "rspec-rails", "~> 6.0"
  gem "factory_bot_rails"
  gem "faker"
end

Setup:

docker compose exec web bundle exec rails generate rspec:install

spec/models/user_spec.rb:

require "rails_helper"

RSpec.describe User, type: :model do
  describe "validations" do
    it { is_expected.to validate_presence_of(:email) }
    it { is_expected.to validate_presence_of(:name) }
    it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
  end

  describe "password" do
    let(:user) { build(:user, password: "password123") }
    it "validates minimum length" do
      user.password = "short"
      expect(user).not_to be_valid
    end
    it "authenticates correct password" do
      user.save!
      expect(user.authenticate("password123")).to eq(user)
    end
  end
end
docker compose exec web bundle exec rspec

The Asset Pipeline #

Rails 7+ has several asset options:

OptionToolBest for
Propshaft (new default)Simple, no processingSimple apps
Sprockets (legacy)SCSS, ERB, fingerprintingLegacy Rails
importmap-railsES modules via <script type="importmap">Hot Module Replacement
jsbundling-railsesbuild, rollup, webpackSPA-like setups
cssbundling-railstailwind, bootstrap, postcssModern styling

In development, assets are served directly by Rails. In production, run assets:precompile at build time and serve from a CDN or Nginx.

When Rails Fits #

Use Rails if:
  ✓ Traditional web apps with many data models
  ✓ You need out-of-the-box admin, ORM, and form handling
  ✓ Mid-to-large-scale apps with complex business logic
  ✓ A team already familiar with Rails
  ✓ A startup MVP needing high productivity

Avoid Rails if:
  ✗ Super-lightweight microservices (Rails feels like overkill)
  ✗ API-only with high performance (use Go/Rust)
  ✗ Heavy real-time apps (ActionCable is limited)
  ✗ The team has no Ruby expertise
  ✗ You need small binaries or low memory footprints

Rails is very productive for conventional web applications. For API microservices, Sinatra or Node.js is lighter. For very high-throughput applications, Go or Rust fits better.

Best Practices #

Separate Dev and Prod Dockerfiles #

Dockerfile.dev for development (full toolchain), Dockerfile for production (slim, multi-stage). Don’t mix concerns.

Cache Gems with Volumes #

Bundle install is slow because it compiles native extensions. Use the bundle volume so gems are cached across restarts.

Use Database URLs #

DATABASE_URL is set via an environment variable, not hardcoded in database.yml. The standard postgresql://user:pass@host:port/db format.

Use Sidekiq for Background Jobs #

Don’t process emails, reports, or heavy tasks in requests. Use ActiveJob + Sidekiq + Redis. The default :async adapter is fine for dev, but :sidekiq is more reliable.

MailHog for Email #

Use MailHog or MailPit in development. Send email to MailHog, view it in the UI. Never send real emails in dev.

Healthcheck Endpoints #

/health must check dependencies (database, redis). Return 503 when any is down. Standard for Kubernetes liveness/readiness probes.

Use ActiveRecord Migrations #

Always generate migrations; don’t edit the schema directly. Commit migrations to Git. For rollbacks: bin/rails db:rollback.

Custom User Models #

From the start of a project, run bin/rails generate devise:install (if using Devise) or write your own User model extending ApplicationRecord. Don’t use the limited default User.

Secret Key Base #

Generate a secure secret key (bin/rails secret) and store it in an environment variable. Don’t commit secrets to Git.

Asynchronous Loading #

Use the zeitwerk autoloader (default since Rails 6+) for eager loading in production. Set config.eager_load = true in production.rb.

Troubleshooting #

Slow Bundle Install #

Make sure the bundle volume is mounted. Check BUNDLE_PATH in the environment. Remove the volume and reinstall if Gemfile.lock conflicts.

Asset Precompile Fails #

Make sure SECRET_KEY_BASE is set (can be a dummy at build time: SECRET_KEY_BASE_DUMMY=1). Check the Rails logs for errors.

Database Connection Refused #

Use depends_on: condition: service_healthy. Add retry logic in database.yml. Check the environment variables.

Port 3000 Already in Use #

lsof -i :3000
# macOS AirPlay can also conflict
sudo killall AirPlayUIAgent

Hot Reload Not Active #

The Rails development server auto-reloads by default. Check config.cache_classes = false in development.rb. Make sure the volume mount includes the source code.

Sidekiq Not Processing Jobs #

Make sure Redis is healthy. Check the Sidekiq logs. Make sure the queue adapter is set: config.active_job.queue_adapter = :sidekiq.

Summary #

  • Rails is ideal for traditional web apps with out-of-the-box ORM, admin, and form handling.
  • Multi-stage Dockerfiles for production: a builder stage installs gems + precompiles assets, a slim runtime stage with Puma.
  • Dockerfile.dev for development: full Ruby + Node.js + Yarn + tools. Auto-reload by default.
  • Split the Gemfile per environment: group :development, :test for testing, group :production for production-only gems.
  • Cache gems with the bundle volume — bundle install is slow due to native extension compilation. Volume caching avoids reinstalls.
  • Bundle volume mounts so gems are cached across restarts. Set BUNDLE_PATH=/usr/local/bundle.
  • Database URLs from environment variables, not hardcoded. The postgresql://user:pass@host:port/db format.
  • Sidekiq + Redis for background jobs. The default :async is fine for dev, :sidekiq is more reliable.
  • MailHog for email testing. SMTP configuration in development.rb. UI at http://localhost:8025.
  • Healthcheck endpoints check dependencies (database, redis). Return 503 when any is down.
  • ActiveRecord migrations — always generate, commit, and apply. Don’t edit schema.rb manually.
  • Custom User models from the start. Use has_secure_password for password hashing.
  • Modern asset pipelines: importmap (default since 7+), jsbundling-rails, or cssbundling-rails. Choose per complexity.
  • RSpec for testing. factory_bot_rails for fixtures. Run with bundle exec rspec.
  • Best practices: separate Dockerfiles, cache gems, environment variables, Sidekiq, MailHog, healthchecks, migrations, custom Users.
  • Alternatives: Sinatra for lightweight APIs, Hanami for a Rails-like approach with a different philosophy.
  • Use Rails if you need productivity and batteries included. Avoid it for super-lightweight microservices or high-throughput APIs.

← Previous: fasthttp   Next: Sinatra →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact