Sinatra #

Sinatra is a very minimal Ruby microframework. With a single app.rb file and a few lines of code, you already have a running HTTP service. Unlike Rails, which is opinionated and batteries-included, Sinatra imposes no structure — you choose your own ORM, view engine, and extra libraries. As a result, Sinatra fits API microservices, internal tools, and small well-scoped services that don’t need Rails.

Docker Compose complements Sinatra simply. The application, database, and cache run as separate containers. No need to install Postgres, Redis, or native dependencies on the host. This article covers a Docker Compose setup for Sinatra local development, from Ruby Dockerfiles, Rack servers, to best practices.

Prerequisites #

Make sure you have installed:

  • Docker and Docker Compose (latest versions)
  • Ruby 3.3+ (optional, for host-side development)
  • Bundler

A Sinatra project structure — intentionally minimal:

my-sinatra-app/
├── app.rb              # Main application
├── config.ru           # Rack config
├── Gemfile
├── Gemfile.lock
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
├── .env
└── .dockerignore

For mid-scale projects, add:

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

Splitting routes into multiple files via Sinatra::Base subclasses is a common pattern as projects grow.

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 \
    && 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"]

A slim production image with Puma behind Rack. A multi-stage build for the smallest possible image.

A Dockerfile for 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 is mounted via a volume

EXPOSE 4567

CMD ["bundle", "exec", "rackup", "-o", "0.0.0.0", "-p", "4567"]

rackup runs the application with auto-reload in 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:pass@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:

A Simple Sinatra Application #

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

Models with Sequel #

Sequel is a powerful, lightweight Ruby ORM. A natural fit for Sinatra, which doesn’t ship a default ORM.

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

The 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

The 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 for Migrations #

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

Run migrations:

docker compose exec web bundle exec rake db:create db:migrate

Hot Reload with Rerun #

For development, rerun watches files and restarts the server automatically.

Add to the Gemfile (group :development):

gem "rerun", require: false

Run with rerun:

docker compose exec web bundle exec rerun -- rackup -o 0.0.0.0 -p 4567

Or update Dockerfile.dev to use rerun:

CMD ["bundle", "exec", "rerun", "--", "rackup", "-o", "0.0.0.0", "-p", "4567"]

rerun watches .rb files in the project directory. On change, it kills the old process and restarts. Fits Sinatra, which has no built-in auto-reload.

Build and Run #

# Build
docker compose build

# Create the database (once)
docker compose run --rm web bundle exec rake db:create
docker compose run --rm web bundle exec rake db:migrate

# Run
docker compose up -d

# View logs
docker compose logs -f web

# Stop
docker compose down

# Full reset
docker compose down -v

Access:

  • API: http://localhost:4567
  • Health: http://localhost:4567/health
  • PostgreSQL: localhost:5432
  • Redis: localhost:6379

Test:

# Create a user
curl -X POST http://localhost:4567/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","name":"Fani","password":"password123"}'

# Get a user
curl http://localhost:4567/api/v1/users/<id>

Modular Sinatra with Multiple Files #

For larger applications, split routes into separate files.

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

This modular pattern lets you split routes into different files, with per-route middleware composition.

When Sinatra Fits #

Use Sinatra if:
  ✓ Lightweight API microservices
  ✓ Internal tools, hooks, webhook receivers
  ✓ Small, clearly-scoped applications
  ✓ You need fast startup and a small footprint
  ✓ Webhook receivers for third-party services
  ✓ Static file servers with extra logic

Avoid Sinatra if:
  ✗ Apps with many models and complex relations (Rails)
  ✗ You need an out-of-the-box admin panel (Rails)
  ✗ Apps with business logic needing conventions (Rails)
  ✗ The team isn't familiar with Ruby

Sinatra is the right choice when you need a minimal HTTP service — a few routes, validation, JSON responses. As complexity grows, consider moving up to Rails.

Best Practices #

Modular Style (Sinatra::Base) #

Don’t use Sinatra::Application (top-level). Use Sinatra::Base subclasses for modular, testable code that can be mounted into a Rack stack.

Separate the Service Layer #

Business logic lives in service classes or models, not route handlers. Handlers only orchestrate: parse requests, call services, format responses.

Use Sequel as the ORM #

Sequel is more powerful and lighter than ActiveRecord. For Sinatra, Sequel is the natural choice. Use Sequel::Model for model classes.

Use a Connection Pool for Redis #

Use connection_pool for thread safety. Puma is multi-threaded, and the Redis client must be pooled.

Use Sequel Migrations #

Don’t create_table manually. Generate migration files and run them via rake db:migrate. Commit migrations to Git.

Hot Reload with Rerun #

Use rerun for auto-restart on file changes. Simpler than shotgun and works in containers.

Secrets from the Environment #

SESSION_SECRET, JWT_SECRET, database passwords — all from the environment. .env.example in the repo, .env in .gitignore.

Use Puma for Production #

Sinatra’s default is WEBrick (deprecated). For production, use Puma or Falcon. Multi-worker, multi-thread.

Troubleshooting #

Port 4567 Already in Use #

lsof -i :4567
# change the port mapping in docker-compose

Slow Bundle Install #

Use the bundle volume for caching. Make sure BUNDLE_PATH=/usr/local/bundle is in the environment.

Hot Reload Not Working #

Make sure rerun is installed. Check the command configuration in docker-compose.yml. Make sure the bind mount includes the source code.

Puma Single Process #

Sinatra’s default is single-process Puma. For production, set workers 2-4 in puma.rb.

# config/puma.rb
workers 2
threads 1, 5
preload_app!
port ENV.fetch("PORT", 4567)

Database Connection Refused #

Use depends_on: condition: service_healthy. Sequel auto-reconnects, but also check DATABASE_URL in the environment.

Summary #

  • Sinatra is ideal for API microservices, internal tools, and small well-scoped services.
  • Sinatra is a microframework — no ORM, admin, or form handling included. Choose your own dependencies.
  • Modular style with Sinatra::Base subclasses. Split routes into separate files, mount into the main app.
  • Multi-stage Dockerfiles for production: a builder stage installs gems, a slim runtime stage with Puma.
  • Dockerfile.dev for development: full Ruby + tools. Use rackup or rerun for auto-restart.
  • Cache gems with the bundle volume. Bundle install is slow due to native extension compilation.
  • Sequel as the ORM — lightweight, powerful, elegant query builder. Fits Sinatra better than ActiveRecord.
  • ConnectionPool for Redis — multi-threaded Puma needs a thread-safe Redis client.
  • Sequel migrations for schema management. Run via rake db:migrate. Commit to Git.
  • Rerun for hot reloadbundle exec rerun -- rackup -o 0.0.0.0 -p 4567. Watches .rb files, restarts the server.
  • JWT for authentication — a custom service, or use sinatra-jwt if you need helpers.
  • Puma for production — multi-worker, multi-thread. The default WEBrick is deprecated.
  • Healthchecks check dependencies. Return 503 when the database is down.
  • Best practices: modular style, service layer, Sequel ORM, ConnectionPool, migrations, rerun, env secrets, Puma.
  • Alternatives: Rails for a full framework, Hanami for a Rails-like approach with a different architecture, pure Rack for super minimal setups.
  • Use Sinatra for lightweight API microservices. Move up to Rails as complexity grows.

← Previous: Rails   Next: Axum →

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