Sinatra #
Sinatra adalah microframework Ruby yang sangat minimalis. Dengan satu file app.rb dan beberapa baris kode, kamu sudah bisa punya HTTP service yang berjalan. Tidak seperti Rails yang opinionated dan batteries-included, Sinatra tidak memaksakan struktur apa pun — kamu pilih sendiri ORM, view engine, dan library tambahan. Hasilnya, Sinatra sangat cocok untuk API microservice, internal tools, dan service dengan scope kecil-jelas yang tidak butuh Rails.
Docker Compose melengkapi Sinatra dengan cara yang sederhana. Aplikasi, database, dan cache berjalan sebagai container terpisah. Tidak perlu install Postgres, Redis, atau native dependency di host. Artikel ini membahas setup Docker Compose untuk local development Sinatra, dari Dockerfile Ruby, Rack server, hingga best practice.
Prasyarat #
Pastikan sudah terinstall:
- Docker dan Docker Compose versi terbaru
- Ruby 3.3+ (opsional, untuk development di host)
- Bundler
Struktur project Sinatra — sengaja minimal:
my-sinatra-app/
├── app.rb # Main application
├── config.ru # Rack config
├── Gemfile
├── Gemfile.lock
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
├── .env
└── .dockerignore
Untuk proyek skala menengah, tambahkan:
my-sinatra-app/
├── app/
│ ├── application.rb # Main Sinatra app
│ ├── routes/
│ │ ├── users.rb
│ │ ├── auth.rb
│ │ └── health.rb
│ ├── services/
│ │ └── user_service.rb
│ ├── models/
│ │ └── user.rb
│ └── helpers/
│ └── auth_helper.rb
├── config.ru
├── config/
│ └── database.yml
├── db/
│ └── migrate/
├── Gemfile
├── Gemfile.lock
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
├── .env
└── .dockerignore
Pemisahan route ke beberapa file lewat Sinatra::Base subclass adalah pola umum untuk proyek yang mulai tumbuh.
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 \
&& rm -rf /var/lib/apt/lists/*
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' \
&& bundle install --jobs 4
COPY . .
FROM ruby:3.3-slim
WORKDIR /app
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY . .
RUN addgroup -S sinatra && adduser -S sinatra -G sinatra
USER sinatra
EXPOSE 4567
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb", "config.ru"]
Image production ramping dengan Puma di belakang Rack. Multi-stage build untuk image sekecil mungkin.
Dockerfile untuk Development #
# Dockerfile.dev
FROM ruby:3.3-slim
WORKDIR /app
ENV BUNDLE_PATH=/usr/local/bundle \
BUNDLE_JOBS=4 \
RACK_ENV=development
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libyaml-dev \
git \
curl \
vim \
&& rm -rf /var/lib/apt/lists/*
RUN gem install bundler
COPY Gemfile Gemfile.lock ./
RUN bundle install
# Source code di-mount via volume
EXPOSE 4567
CMD ["bundle", "exec", "rackup", "-o", "0.0.0.0", "-p", "4567"]
rackup menjalankan aplikasi dengan auto-reload di development.
docker-compose.yml #
services:
web:
build:
context: .
dockerfile: Dockerfile.dev
image: sinatra-app:dev
container_name: sinatra-web
command: bundle exec rackup -o 0.0.0.0 -p 4567
volumes:
- ./:/app
- bundle:/usr/local/bundle
ports:
- "4567:4567"
environment:
- RACK_ENV=development
- DATABASE_URL=postgres://sinatra:dev@db:5432/sinatraapp
- REDIS_URL=redis://cache:6379/0
- SESSION_SECRET=local-dev-session-secret
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
db:
image: postgres:16-alpine
container_name: sinatra-db
environment:
- POSTGRES_USER=sinatra
- POSTGRES_PASSWORD=dev
- POSTGRES_DB=sinatraapp
volumes:
- db-data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sinatra -d sinatraapp"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432"
cache:
image: redis:7-alpine
container_name: sinatra-cache
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
volumes:
- cache-data:/data
ports:
- "6379:6379"
volumes:
db-data:
cache-data:
bundle:
Contoh Aplikasi Sinatra Sederhana #
Gemfile:
source "https://rubygems.org"
ruby "3.3.0"
gem "sinatra", "~> 4.0"
gem "puma", "~> 6.0"
gem "rackup", "~> 2.1"
gem "pg", "~> 1.5"
gem "sequel", "~> 5.0"
gem "redis", "~> 5.0"
gem "connection_pool", "~> 2.4"
gem "rake", "~> 13.0"
gem "bcrypt", "~> 3.1"
gem "jwt", "~> 2.7"
group :development, :test do
gem "rerun", require: false
gem "rspec", "~> 3.12"
gem "rack-test", "~> 2.1"
end
app.rb — Modular Style:
require "sinatra/base"
require "json"
require "securerandom"
require "bcrypt"
require "jwt"
require "sequel"
require "redis"
require "connection_pool"
class Application < Sinatra::Base
set :show_exceptions, false
set :raise_errors, false
set :dump_errors, true
set :logging, true
# Database setup
DB = Sequel.connect(ENV.fetch("DATABASE_URL"))
DB.extension :pg_array, :pg_json
Sequel::Model.plugin :timestamps
Sequel::Model.plugin :validation_helpers
# Redis setup
REDIS = ConnectionPool.new(size: 5) { Redis.new(url: ENV.fetch("REDIS_URL")) }
# Helpers
helpers do
def json_response(data, status_code = 200)
content_type :json
halt status_code, JSON.generate(data)
end
def parsed_body
@parsed_body ||= begin
body = request.body.read
body.empty? ? {} : JSON.parse(body)
end
end
def authenticate!
token = request.env["HTTP_AUTHORIZATION"]&.split(" ")&.last
payload = JwtService.decode(token) if token
@current_user = User[UUID.parse(payload["user_id"])] if payload
rescue
json_response({ error: "unauthorized" }, 401)
end
def current_user
@current_user
end
end
# Routes
get "/health" do
db_ok = DB["SELECT 1"].any?
json_response({
status: db_ok ? "ok" : "degraded",
database: db_ok ? "up" : "down",
}, db_ok ? 200 : 503)
end
post "/api/v1/users" do
data = parsed_body
user = UserService.create(
email: data["email"],
name: data["name"],
password: data["password"],
)
json_response(user.to_hash, 201)
rescue UserService::ValidationError => e
json_response({ error: e.message }, 422)
rescue Sequel::ValidationFailed => e
json_response({ error: e.errors.full_messages }, 422)
end
get "/api/v1/users/:id" do
user = User[UUID.parse(params["id"])]
json_response({ error: "not found" }, 404) unless user
json_response(user.to_hash)
end
get "/api/v1/users" do
authenticate!
page = (params["page"] || 1).to_i
per_page = [(params["per_page"] || 20).to_i, 100].min
users = User.order(Sequel.desc(:created_at))
.offset((page - 1) * per_page)
.limit(per_page)
.all
json_response({
data: users.map(&:to_hash),
page: page,
per_page: per_page,
})
end
delete "/api/v1/users/:id" do
authenticate!
user = User[UUID.parse(params["id"])]
json_response({ error: "not found" }, 404) unless user
user.destroy
status 204
end
# Error handlers
error JSON::ParserError do
json_response({ error: "invalid JSON" }, 400)
end
error Sequel::DatabaseError do
json_response({ error: "database error" }, 503)
end
error 404 do
json_response({ error: "not found" }, 404)
end
error 500 do
json_response({ error: "internal server error" }, 500)
end
end
config.ru:
require_relative "app"
run Application
Model dengan Sequel #
Sequel adalah ORM Ruby yang powerfull dan ringan. Cocok untuk Sinatra yang tidak bawa ORM default.
models/user.rb:
require "securerandom"
class User < Sequel::Model(:users)
plugin :timestamps
plugin :validation_helpers
plugin :json_serializer
plugin :uuid, field: :id
attr_accessor :password
def validate
super
validates_presence [:email, :name, :password_hash]
validates_unique :email
validates_format /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/, :email
validates_max_length 100, :name
end
def password=(value)
self.password_hash = BCrypt::Password.create(value)
@password = value
end
def authenticate(value)
password_hash && BCrypt::Password.new(password_hash) == value
end
def to_hash
{
id: id,
email: email,
name: name,
is_verified: is_verified,
created_at: created_at&.iso8601,
}
end
end
db/migrate/001_create_users.rb:
Sequel.migration do
change do
create_table(:users) do
column :id, :uuid, primary_key: true, default: Sequel::UUIDGenerator.new
String :email, null: false, size: 255
String :name, null: false, size: 255
String :password_hash, null: false
TrueClass :is_verified, default: false, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
index :email, unique: true
end
end
end
Service Layer #
services/user_service.rb:
class UserService
class ValidationError < StandardError; end
MIN_PASSWORD_LENGTH = 8
def self.create(email:, name:, password:)
raise ValidationError, "password too short" if password.to_s.length < MIN_PASSWORD_LENGTH
User.create(
email: email.to_s.downcase.strip,
name: name.to_s.strip,
password: password,
)
end
def self.authenticate(email:, password:)
user = User[email: email.to_s.downcase.strip]
return nil unless user
return nil unless user.authenticate(password)
user
end
end
JWT Service #
services/jwt_service.rb:
require "jwt"
class JwtService
ALGORITHM = "HS256".freeze
def self.secret
ENV.fetch("SESSION_SECRET", "dev-secret-change-me")
end
def self.encode(payload, exp = 24 * 3600)
JWT.encode(
payload.merge(exp: Time.now.to_i + exp),
secret,
ALGORITHM,
)
end
def self.decode(token)
JWT.decode(token, secret, true, algorithm: ALGORITHM).first
rescue JWT::DecodeError, JWT::ExpiredSignature
nil
end
end
Rake Tasks untuk Migration #
Rakefile:
require "sequel"
require "logger"
Sequel.extension :migration
namespace :db do
desc "Run migrations"
task :migrate, [:version] do |_, args|
db = Sequel.connect(ENV.fetch("DATABASE_URL"))
if args[:version]
Sequel::Migrator.run(db, "db/migrate", target: args[:version].to_i)
else
Sequel::Migrator.run(db, "db/migrate")
end
db.disconnect
end
desc "Rollback last migration"
task :rollback do
db = Sequel.connect(ENV.fetch("DATABASE_URL"))
current = db[:schema_info].first[:version] rescue 0
Sequel::Migrator.run(db, "db/migrate", target: current - 1)
db.disconnect
end
desc "Create database"
task :create do
uri = URI.parse(ENV.fetch("DATABASE_URL"))
db_name = uri.path[1..]
uri.path = "/postgres"
admin = Sequel.connect(uri.to_s)
admin.run("CREATE DATABASE #{db_name}")
admin.disconnect
puts "Database #{db_name} created"
end
desc "Drop database"
task :drop do
uri = URI.parse(ENV.fetch("DATABASE_URL"))
db_name = uri.path[1..]
uri.path = "/postgres"
admin = Sequel.connect(uri.to_s)
admin.run("DROP DATABASE IF EXISTS #{db_name}")
admin.disconnect
puts "Database #{db_name} dropped"
end
end
Jalankan migration:
docker compose exec web bundle exec rake db:create db:migrate
Hot Reload dengan Rerun #
Untuk development, rerun memantau file dan restart server otomatis.
Tambah di Gemfile (group :development):
gem "rerun", require: false
Jalankan dengan rerun:
docker compose exec web bundle exec rerun -- rackup -o 0.0.0.0 -p 4567
Atau update Dockerfile.dev untuk pakai rerun:
CMD ["bundle", "exec", "rerun", "--", "rackup", "-o", "0.0.0.0", "-p", "4567"]
rerun memantau file .rb di direktori project. Saat ada perubahan, ia kill proses lama dan start ulang. Cocok untuk Sinatra yang tidak punya auto-reload built-in.
Build dan Run #
# Build
docker compose build
# Buat database (sekali)
docker compose run --rm web bundle exec rake db:create
docker compose run --rm web bundle exec rake db:migrate
# Jalankan
docker compose up -d
# Lihat log
docker compose logs -f web
# Stop
docker compose down
# Reset total
docker compose down -v
Akses:
- API: http://localhost:4567
- Health: http://localhost:4567/health
- PostgreSQL:
localhost:5432 - Redis:
localhost:6379
Test:
# Create user
curl -X POST http://localhost:4567/api/v1/users \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","name":"Fani","password":"password123"}'
# Get user
curl http://localhost:4567/api/v1/users/<id>
Modular Sinatra dengan Multiple Files #
Untuk aplikasi yang lebih besar, pecah route ke file terpisah.
app/application.rb:
require "sinatra/base"
class Application < Sinatra::Base
set :show_exceptions, false
set :raise_errors, false
# Register sub-apps (modular routes)
use Routes::Health
use Routes::Auth
use Routes::Users
error 404 do
content_type :json
JSON.generate(error: "not found")
end
end
app/routes/health.rb:
module Routes
class Health < Sinatra::Base
get "/health" do
content_type :json
JSON.generate(status: "ok")
end
end
end
app/routes/users.rb:
module Routes
class Users < Sinatra::Base
get "/api/v1/users/:id" do
user = ::User[UUID.parse(params["id"])]
halt 404, JSON.generate(error: "not found") unless user
content_type :json
JSON.generate(user.to_hash)
end
post "/api/v1/users" do
data = JSON.parse(request.body.read)
user = UserService.create(**data.transform_keys(&:to_sym))
status 201
content_type :json
JSON.generate(user.to_hash)
end
end
end
Pola modular ini memungkinkan route dipisah ke file berbeda, dengan middleware composition per-route.
Kapan Sinatra Tepat #
Pakai Sinatra jika:
✓ API microservice ringan
✓ Internal tools, hook, webhook receiver
✓ Aplikasi dengan scope kecil dan jelas
✓ Butuh startup cepat, footprint kecil
✓ Webhook receiver untuk third-party service
✓ Static file server dengan logika tambahan
Hindari Sinatra jika:
✗ Aplikasi dengan banyak model dan relasi kompleks (Rails)
✗ Butuh admin panel out-of-the-box (Rails)
✗ Aplikasi dengan business logic yang butuh convention (Rails)
✗ Tim tidak familiar dengan Ruby
Sinatra adalah pilihan yang tepat ketika kamu butuh HTTP service yang minimal — beberapa route, validasi, response JSON. Saat kompleksitas tumbuh, pertimbangkan naik ke Rails.
Best Practice #
Modular Style (Sinatra::Base) #
Jangan pakai Sinatra::Application (top-level). Pakai Sinatra::Base subclass untuk modular, testable, dan bisa di-mount ke Rack stack.
Pisahkan Service Layer #
Business logic di service class atau model, bukan di route handler. Handler cuma orchestrasi: parse request, panggil service, format response.
Pakai Sequel untuk ORM #
Sequel lebih powerfull dan ringan dari ActiveRecord. Untuk Sinatra, Sequel adalah pilihan natural. Pakai Sequel::Model untuk model class.
Pakai Connection Pool untuk Redis #
Pakai connection_pool agar thread aman. Puma pakai multi-thread, dan Redis client harus di-pool.
Pakai Sequel Migration #
Jangan create_table manual. Generate file migration dan jalankan via rake db:migrate. Commit migration ke Git.
Hot Reload dengan Rerun #
Pakai rerun untuk auto-restart saat file berubah. Lebih sederhana dari shotgun dan bekerja di container.
Secret dari Environment #
SESSION_SECRET, JWT_SECRET, password database — semua dari environment. .env.example di repo, .env di-.gitignore.
Pakai Puma untuk Production #
Default Sinatra pakai WEBrick (sudah deprecated). Untuk production, pakai Puma atau Falcon. Multi-worker, multi-thread.
Troubleshooting #
Port 4567 Sudah Dipakai #
lsof -i :4567
# ubah port mapping di docker-compose
Bundle Install Lambat #
Pakai volume bundle untuk cache. Pastikan BUNDLE_PATH=/usr/local/bundle di environment.
Hot Reload Tidak Jalan #
Pastikan rerun terinstall. Cek konfigurasi command di docker-compose.yml. Pastikan bind mount include source code.
Puma Single Process #
Default Sinatra pakai single-process Puma. Untuk production, set workers 2-4 di puma.rb.
# config/puma.rb
workers 2
threads 1, 5
preload_app!
port ENV.fetch("PORT", 4567)
Database Connection Refused #
Pakai depends_on: condition: service_healthy. Sequel akan auto-reconnect, tapi cek juga DATABASE_URL di environment.
Ringkasan #
- Sinatra ideal untuk API microservice, internal tools, dan service dengan scope kecil-jelas.
- Sinatra adalah microframework — tidak bawa ORM, admin, atau form handling. Pilih sendiri dependency-nya.
- Modular style dengan
Sinatra::Basesubclass. Pisahkan route ke file berbeda, mount ke aplikasi utama.- Dockerfile multi-stage untuk production: builder stage install gems, runtime stage ramping dengan Puma.
- Dockerfile.dev untuk development: full Ruby + tools. Pakai
rackupataurerununtuk auto-restart.- Cache gems dengan volume
bundle. Bundle install lambat karena compile native extension.- Sequel untuk ORM — ringan, powerfull, query builder elegan. Lebih cocok untuk Sinatra dari ActiveRecord.
- ConnectionPool untuk Redis — Puma multi-thread butuh Redis client yang thread-safe.
- Sequel migration untuk schema management. Jalankan via
rake db:migrate. Commit ke Git.- Rerun untuk hot reload —
bundle exec rerun -- rackup -o 0.0.0.0 -p 4567. Pantau file.rb, restart server.- JWT untuk authentication — custom service, atau pakai
sinatra-jwtjika butuh helper.- Puma untuk production — multi-worker, multi-thread. Default WEBrick sudah deprecated.
- Healthcheck cek dependency. Return 503 kalau database down.
- Best practice: modular style, service layer, Sequel ORM, ConnectionPool, migration, rerun, env secret, Puma.
- Alternatif: Rails untuk full framework, Hanami untuk Rails-like dengan arsitektur berbeda, Rack murni untuk super minimal.
- Pakai Sinatra untuk API microservice ringan. Naik ke Rails saat kompleksitas bertambah.