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 Gemfile per environment: group :development, :test untuk testing, group :production untuk 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 :async cukup untuk dev, :sidekiq lebih 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_password untuk password hashing.
  • Asset pipeline modern: importmap (default 7+), jsbundling-rails, atau cssbundling-rails. Pilih sesuai kompleksitas.
  • RSpec untuk testing. factory_bot_rails untuk fixture. Run dengan bundle 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.

← Sebelumnya: fasthttp   Berikutnya: Sinatra →

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