Rails #
Ruby on Rails adalah web framework yang opinionated, batteries-included, dan sudah berusia lebih dari 20 tahun. Dengan konvensi “Convention over Configuration”, Rails memungkinkan developer membangun aplikasi web lengkap — model, view, controller, ORM (ActiveRecord), migration, asset pipeline, mailer, job queue, cache, dan WebSocket — tanpa harus memilih library satu per satu. Docker Compose melengkapi Rails dengan cara yang signifikan karena setup Rails terkenal punya banyak native dependency (libpq, nodejs, yarn, imagemagick) yang sering bentrok antar developer.
Artikel ini membahas setup Docker Compose untuk local development Rails, dari Dockerfile untuk Ruby, docker-compose dengan Postgres + Redis + Sidekiq, asset pipeline, hingga best practice.
Prasyarat #
Pastikan sudah terinstall:
- Docker dan Docker Compose versi terbaru
- Ruby 3.3+ (opsional, untuk development di host)
- Node.js 20+ dan Yarn (untuk asset)
- Git
Struktur project Rails standar (dari 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/ (atau spec/)
├── tmp/
├── vendor/
├── Gemfile
├── Gemfile.lock
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
├── .env
└── .dockerignore
Struktur ini dihasilkan oleh rails new myapp --database=postgresql --css=tailwind. Untuk Rails API only (tanpa view), pakai rails new myapp --api.
Dockerfile untuk 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 Gemfile dulu
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'production' \
&& bundle install --jobs 4
COPY . .
# Precompile assets untuk production
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile
# Stage kedua: runtime ramping
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 dan 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"]
Image production ramping dengan Puma. Asset sudah di-precompile di builder stage.
Dockerfile untuk 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 di-mount via volume
EXPOSE 3000
CMD ["bin/rails", "server", "-b", "0.0.0.0"]
BUNDLE_PATH=/usr/local/bundle memastikan gems di-install di path yang persistent (bisa di-mount sebagai volume untuk performa).
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:dev@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:dev@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:
Penjelasan Service #
web — service utama Rails. Pakai bin/rails server dengan binding ke 0.0.0.0 agar bisa diakses dari host. Mount source code dan bundle volume (untuk cache gems). Volume rails-tmp dan rails-log agar data di host tetap bersih.
worker — Sidekiq worker untuk background job. Diaktifkan dengan --profile with-worker.
db — PostgreSQL untuk data persisten. Healthcheck pg_isready.
cache — Redis untuk cache, session, dan Sidekiq queue.
mailhog — mock SMTP untuk development. Kirim email ke MailHog di port 1025, lihat di UI 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"] %>
Pakai DATABASE_URL dari environment variable, atau host/username/password individual. Format postgresql://user:pass@host:port/db lebih portable.
Cache dan Session Store #
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 untuk Background Job #
Gemfile (tambah):
gem "sidekiq", "~> 7.0"
gem "redis", "~> 5.0"
config/sidekiq.yml:
:concurrency: 5
:queues:
- default
- mailers
- active_storage
- low
config/initializers/sidekiq.rb:
require "sidekit"
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
Job example (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 Model dan Migration #
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 dari Rails 7+ membutuhkan bcrypt di Gemfile. Ini otomatis validasi password dan method authenticate.
Controller dan 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
Akses di http://localhost:3000/health. Return 503 kalau database down.
Build dan Run #
# Build image
docker compose build
# Install gems (jika Gemfile berubah)
docker compose run --rm web bundle install
# Buat database
docker compose run --rm web bin/rails db:create
# Run migration
docker compose run --rm web bin/rails db:migrate
# Jalankan semua service
docker compose up -d
# Jalankan dengan Sidekiq
docker compose --profile with-worker up -d
# Lihat log
docker compose logs -f web
# Rails console
docker compose exec web bin/rails console
# Generate migration baru
docker compose exec web bin/rails generate migration CreatePosts
# Reset database
docker compose down -v
docker compose up -d
docker compose run --rm web bin/rails db:create db:migrate db:seed
Akses:
- 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 current user
curl http://localhost:3000/api/v1/users/me \
-H "Authorization: Bearer <token>"
Testing dengan 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
Asset Pipeline #
Rails 7+ punya beberapa pilihan untuk asset:
| Opsi | Tool | Cocok untuk |
|---|---|---|
| Propshaft (default baru) | Sederhana, no processing | Aplikasi sederhana |
| 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 setup |
| cssbundling-rails | tailwind, bootstrap, postcss | Styling modern |
Untuk development, asset di-serve langsung oleh Rails. Untuk production, jalankan assets:precompile saat build dan serve dari CDN atau Nginx.
Kapan Rails Tepat #
Pakai Rails jika:
✓ Aplikasi web tradisional dengan banyak model data
✓ Butuh admin, ORM, dan form handling out-of-the-box
✓ Aplikasi skala menengah-besar dengan business logic kompleks
✓ Tim yang sudah familiar dengan Rails
✓ Startup MVP yang butuh produktivitas tinggi
Hindari Rails jika:
✗ Microservice super ringan (Rails terasa overkill)
✗ API only dengan performa tinggi (pakai Go/Rust)
✗ Aplikasi real-time berat (ActionCable terbatas)
✗ Tim tidak punya expertise Ruby
✗ Butuh binary kecil atau memory footprint rendah
Rails sangat produktif untuk aplikasi web konvensional. Untuk API microservice, Sinatra atau Node.js lebih ringan. Untuk aplikasi dengan throughput sangat tinggi, Go atau Rust lebih cocok.
Best Practice #
Pisahkan Dockerfile Dev dan Prod #
Dockerfile.dev untuk development (full toolchain), Dockerfile untuk production (ramping, multi-stage). Jangan campur concern.
Cache Gems dengan Volume #
Bundle install itu lambat karena compile native extension. Pakai volume bundle agar gems di-cache antar restart.
Pakai Database URL #
DATABASE_URL di-set via environment variable, bukan hardcode di database.yml. Format standar postgresql://user:pass@host:port/db.
Pakai Sidekiq untuk Background Job #
Jangan proses email, report, atau task berat di request. Pakai ActiveJob + Sidekiq + Redis. Default adapter :async cukup untuk dev, tapi :sidekiq lebih reliable.
MailHog untuk Email #
Pakai MailHog atau MailPit di development. Kirim email ke MailHog, lihat di UI. Jangan pernah kirim email sungguhan di dev.
Healthcheck Endpoint #
/health harus cek dependency (database, redis). Return 503 kalau ada yang down. Standard untuk Kubernetes liveness/readiness probe.
Pakai ActiveRecord Migration #
Selalu generate migration, jangan edit schema langsung. Commit migration ke Git. Untuk rollback: bin/rails db:rollback.
Custom User Model #
Sejak awal proyek, generate bin/rails generate devise:install (kalau pakai Devise) atau tulis User model sendiri yang extend ApplicationRecord. Jangan pakai User default yang terbatas.
Secret Key Base #
Generate secret key yang aman (bin/rails secret) dan simpan di environment variable. Jangan commit secret ke Git.
Asynchronous Loading #
Pakai zeitwerk autoloader (default Rails 6+) untuk eager load di production. Set config.eager_load = true di production.rb.
Troubleshooting #
Bundle Install Lambat #
Pastikan volume bundle di-mount. Cek BUNDLE_PATH di environment. Hapus volume dan re-install kalau Gemfile.lock bentrok.
Asset Precompile Gagal #
Pastikan SECRET_KEY_BASE di-set (bisa dummy di build: SECRET_KEY_BASE_DUMMY=1). Cek error di log Rails.
Database Connection Refused #
Pakai depends_on: condition: service_healthy. Tambah retry logic di database.yml. Cek environment variable.
Port 3000 Sudah Dipakai #
lsof -i :3000
# macOS AirPlay juga bisa bentrok
sudo killall AirPlayUIAgent
Hot Reload Tidak Aktif #
Rails development server auto-reload by default. Cek config.cache_classes = false di development.rb. Pastikan volume mount include source code.
Sidekiq Tidak Proses Job #
Pastikan Redis healthy. Cek log Sidekiq. Pastikan queue adapter di-set: config.active_job.queue_adapter = :sidekiq.
Ringkasan #
- Rails ideal untuk aplikasi web tradisional dengan ORM, admin, dan form handling out-of-the-box.
- Dockerfile multi-stage untuk production: builder stage install gems + precompile asset, runtime stage ramping dengan Puma.
- Dockerfile.dev untuk development: full Ruby + Node.js + Yarn + tools. Auto-reload by default.
- Pisahkan
Gemfileper environment:group :development, :testuntuk testing,group :productionuntuk production-only gem.- Cache gems dengan volume
bundle— bundle install lambat karena compile native extension. Volume cache agar tidak perlu re-install.- Bundle volume mount agar gems di-cache antar restart. Set
BUNDLE_PATH=/usr/local/bundle.- Database URL dari environment variable, bukan hardcode. Format
postgresql://user:pass@host:port/db.- Sidekiq + Redis untuk background job. Default
:asynccukup untuk dev,:sidekiqlebih reliable.- MailHog untuk testing email. Konfigurasi SMTP di
development.rb. UI di http://localhost:8025.- Healthcheck endpoint cek dependency (database, redis). Return 503 kalau ada yang down.
- ActiveRecord migration selalu generate, commit, dan apply. Jangan edit schema.rb manual.
- Custom User model sejak awal. Pakai
has_secure_passworduntuk password hashing.- Asset pipeline modern: importmap (default 7+), jsbundling-rails, atau cssbundling-rails. Pilih sesuai kompleksitas.
- RSpec untuk testing.
factory_bot_railsuntuk fixture. Run denganbundle exec rspec.- Best practice: pisahkan Dockerfile, cache gems, env variable, Sidekiq, MailHog, healthcheck, migration, custom User.
- Alternatif: Sinatra untuk API ringan, Hanami untuk Rails-like dengan filosofi berbeda.
- Pakai Rails jika butuh produktivitas dan batteries-included. Hindari untuk microservice super ringan atau API high-throughput.