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::Base subclass. 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 rackup atau rerun untuk 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 reloadbundle exec rerun -- rackup -o 0.0.0.0 -p 4567. Pantau file .rb, restart server.
  • JWT untuk authentication — custom service, atau pakai sinatra-jwt jika 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.

← Sebelumnya: Rails   Berikutnya: Axum →

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