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'
endRunning 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::ApplicationAnd 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=productiontells Sinatra and Rack to use production behavior (caching templates, terse error pages).- Copying
Gemfile/Gemfile.lockbefore the rest of the source caches thebundle installlayer.
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
| Concern | Setting |
|---|---|
| Port | ENV['PORT'], bind 0.0.0.0 |
| App server | Puma, not WEBrick |
| Environment | RACK_ENV=production |
| Concurrency | WEB_CONCURRENCY workers + threads |
| Health check | dedicated /health route |
| Gems | without 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 mainIf 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)