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:
| Option | Tool | Best for |
|---|---|---|
| Propshaft (new default) | Simple, no processing | Simple apps |
| Sprockets (legacy) | SCSS, ERB, fingerprinting | Legacy Rails |
| importmap-rails | ES modules via <script type="importmap"> | Hot Module Replacement |
| jsbundling-rails | esbuild, rollup, webpack | SPA-like setups |
| cssbundling-rails | tailwind, bootstrap, postcss | Modern 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
Gemfileper environment:group :development, :testfor testing,group :productionfor production-only gems.- Cache gems with the
bundlevolume — 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/dbformat.- Sidekiq + Redis for background jobs. The default
:asyncis fine for dev,:sidekiqis 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_passwordfor password hashing.- Modern asset pipelines: importmap (default since 7+), jsbundling-rails, or cssbundling-rails. Choose per complexity.
- RSpec for testing.
factory_bot_railsfor fixtures. Run withbundle 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.