Back to Blog
Tutorial9 min read2026-07-05

How to Deploy a Sinatra Ruby App

Sinatra is the minimal Ruby web framework. Here's how to take a single-file app to a production-grade container with Puma, proper port binding, and a clean Dockerfile.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Sinatra is delightfully small. A working web app can be a single file. But that minimalism means Sinatra gives you almost nothing for production — no app server defaults, no asset pipeline, no opinionated structure. You have to make the production choices yourself. This guide shows how to deploy a Sinatra app properly with Puma in a container.

A minimal Sinatra app

Here's the canonical starting point:

# app.rb
require 'sinatra'

get '/' do
  'Hello from Sinatra'
end

get '/health' do
  status 200
  'ok'
end

Running ruby app.rb starts Sinatra's built-in WEBrick/Puma server on port 4567. That's fine for development, but for production you want explicit control over the app server and the port.

Use a config.ru and Puma

Production Sinatra should be a Rack app served by Puma. Create a config.ru:

# config.ru
require './app'
run Sinatra::Application

And a Gemfile:

source 'https://rubygems.org'
ruby '3.3.0'

gem 'sinatra'
gem 'puma'
gem 'rackup'

Then a puma.rb config so the server binds correctly inside a container:

# config/puma.rb
port ENV.fetch('PORT', 9292)
workers ENV.fetch('WEB_CONCURRENCY', 2)
threads 5, 5
preload_app!

The critical line is port ENV.fetch('PORT', ...). Platforms inject the port your app must listen on via the PORT env var. If you hardcode 4567, the platform's health checks and ingress won't reach your app — the single most common reason a Ruby deploy shows "no healthy upstream."

Binding to the right interface

Inside a container, you must bind to 0.0.0.0, not 127.0.0.1. Puma binds to 0.0.0.0 by default when you set port, but if you use bind explicitly, write bind "tcp://0.0.0.0:#{ENV.fetch('PORT', 9292)}". Binding to localhost means the process is unreachable from outside the container.

The Dockerfile

FROM ruby:3.3-slim AS base

RUN apt-get update -qq && apt-get install -y --no-install-recommends \
    build-essential \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' \
 && bundle install --jobs 4 --retry 3

COPY . .

ENV RACK_ENV=production
EXPOSE 9292
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

A few details worth calling out:

  • bundle config set without 'development test' keeps dev gems out of the image, shrinking it and avoiding accidental dev dependencies at runtime.
  • RACK_ENV=production tells Sinatra and Rack to use production behavior (caching templates, terse error pages).
  • Copying Gemfile/Gemfile.lock before the rest of the source caches the bundle install layer.

Multi-stage for a slimmer image

If you have native gem extensions, build them in one stage and copy the installed gems into a clean runtime stage:

FROM ruby:3.3-slim AS build
RUN apt-get update -qq && apt-get install -y build-essential
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment true \
 && bundle config set --local without 'development test' \
 && bundle install

FROM ruby:3.3-slim
WORKDIR /app
COPY --from=build /app/vendor/bundle /app/vendor/bundle
COPY --from=build /usr/local/bundle/config /usr/local/bundle/config
COPY . .
ENV RACK_ENV=production
EXPOSE 9292
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

This drops build-essential from the final image.

Health checks

Give the platform a lightweight endpoint to probe. The /health route above is enough. Configure it as your health check path so the platform knows when the app is ready before routing traffic.

Production checklist

ConcernSetting
PortENV['PORT'], bind 0.0.0.0
App serverPuma, not WEBrick
EnvironmentRACK_ENV=production
ConcurrencyWEB_CONCURRENCY workers + threads
Health checkdedicated /health route
Gemswithout development test

Deploying

Commit the Dockerfile, config.ru, and config/puma.rb, then push and connect the repo. The platform auto-detects the Dockerfile (or buildpacks for Ruby if you skip the Dockerfile), builds, and deploys. Set any env vars your app needs. Live logs let you confirm Puma booted and bound to the injected port.

git push origin main

If you don't include a Dockerfile, buildpack auto-detection will pick up your Gemfile and run Puma via config.ru automatically — but the explicit Dockerfile gives you reproducibility.

Conclusion

Sinatra's minimalism is its strength, but production readiness is on you: Puma, 0.0.0.0 binding, the injected PORT, and a health endpoint. Nail those and a single-file app deploys as reliably as any large framework.

Try it on PandaStack's free tier — connect your Sinatra repo at [dashboard.pandastack.io](https://dashboard.pandastack.io) and it builds and runs with one push.

References

  • [Sinatra: README and Configuration](https://sinatrarb.com/intro.html)
  • [Puma Configuration](https://github.com/puma/puma#configuration)
  • [Rack: config.ru and rackup](https://github.com/rack/rack)
  • [Bundler: Deploying Your Application](https://bundler.io/guides/deploying.html)
  • [Ruby Official Docker Images](https://hub.docker.com/_/ruby)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also