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::Basesubclasses. 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
rackuporrerunfor auto-restart.- Cache gems with the
bundlevolume. 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 reload —
bundle exec rerun -- rackup -o 0.0.0.0 -p 4567. Watches.rbfiles, restarts the server.- JWT for authentication — a custom service, or use
sinatra-jwtif 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.