Hanami 2 is a refreshing take on Ruby web development — explicit dependency injection, a slice-based architecture, and ROM (Ruby Object Mapper) instead of Active Record. It's less ubiquitous than Rails, which means deployment guides are scarce. This article covers a production container deployment of Hanami 2 with a managed PostgreSQL database.
What makes Hanami deployment different
Unlike Rails, Hanami doesn't ship a kitchen-sink convention. Key things to know for production:
- Hanami uses ROM with the
hanami-dbintegration; migrations are managed viahanami db migrate. - The app boots through a container (
Hanami.app) with explicit providers. - Assets are handled by
hanami-assets, which wraps esbuild — you precompile at build time. - The web server is Puma, configured in
config/puma.rb.
Database configuration
Hanami's database connection is driven by the DATABASE_URL environment variable. In config/app.rb or via the hanami-db provider, the connection is read from the environment, so you rarely hardcode anything:
# .env.production (values come from real env vars in prod)
DATABASE_URL=postgres://user:pass@host:5432/myappWith a managed PostgreSQL instance, you link it and the platform injects DATABASE_URL. Hanami's ROM setup reads it directly. The one adjustment: ROM's pg adapter expects the postgres:// scheme, which managed providers typically use, so no rewriting is needed (unlike Doctrine in PHP).
Production Dockerfile
Because Hanami precompiles assets with esbuild, you need Node available at build time. A multi-stage build keeps the runtime image lean:
# ---- Build stage ----
FROM ruby:3.3-slim AS build
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
build-essential libpq-dev curl gnupg \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' \
&& bundle install --jobs 4
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV HANAMI_ENV=production
RUN bundle exec hanami assets compile
# ---- Runtime stage ----
FROM ruby:3.3-slim
RUN apt-get update -qq && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /app /app
ENV HANAMI_ENV=production
EXPOSE 2300
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]libpq-dev is needed to compile the pg gem in the build stage; libpq5 is the lighter runtime library.
Puma configuration
# config/puma.rb
max_threads = ENV.fetch('PUMA_MAX_THREADS', 5).to_i
threads 1, max_threads
port ENV.fetch('PORT', 2300)
environment ENV.fetch('HANAMI_ENV', 'development')
workers ENV.fetch('WEB_CONCURRENCY', 2).to_i
preload_app!As with any container deploy, the PORT env var is authoritative. Hanami's default dev port is 2300, but in production you bind whatever the platform injects.
Running migrations
ROM migrations live in config/db/migrate/. Run them as a release step, never during the image build:
bundle exec hanami db migrateIf you're seeding reference data, do it in a separate idempotent step. On PandaStack, wire hanami db migrate as a one-off job or a once-per-release cronjob so the database is migrated before the new version takes traffic.
Secrets and sessions
Hanami uses a session secret for signed/encrypted cookies. Set it via env var:
HANAMI_WEB_SESSIONS_SECRET=$(bundle exec hanami secret)Generate it once and store it as a stable secret. As with any framework, a rotating secret logs everyone out on each deploy.
Environment variable summary
| Variable | Purpose |
|---|---|
HANAMI_ENV | production |
DATABASE_URL | injected by managed DB link |
PORT | bind address, injected |
WEB_CONCURRENCY | Puma workers |
HANAMI_WEB_SESSIONS_SECRET | cookie signing key |
Health checks and observability
Add a simple action that returns 200 so the platform can probe readiness. Hanami actions are explicit classes:
# app/actions/health/show.rb
module MyApp
module Actions
module Health
class Show < MyApp::Action
def handle(_request, response)
response.status = 200
response.body = 'ok'
end
end
end
end
endRoute it at /health in config/routes.rb.
Deploying
Commit the Dockerfile, push, connect the repo, and link your PostgreSQL instance so DATABASE_URL is injected. Add the migration step, set your session secret, and deploy. Build and app logs stream live, so a failed hanami assets compile (often a missing Node dependency) is visible immediately.
git push origin mainWhy Hanami is worth the slightly heavier setup
Hanami's slice architecture keeps large apps maintainable, and ROM's separation of persistence from domain logic ages well. The deployment is marginally more involved than Rails only because you precompile esbuild assets and run ROM migrations — both one-time setup costs.
Conclusion
Hanami 2 deploys cleanly once you account for esbuild asset compilation at build time, ROM migrations as a release step, a stable session secret, and the injected PORT. The framework's explicitness pays off in production maintainability.
Spin up a Hanami app plus a managed PostgreSQL database on PandaStack's free tier — connect your repo at [dashboard.pandastack.io](https://dashboard.pandastack.io) and the database wires itself in via DATABASE_URL.
References
- [Hanami Guides](https://guides.hanamirb.org/)
- [Hanami: Database (hanami-db / ROM)](https://guides.hanamirb.org/v2.2/database/overview/)
- [Hanami: Assets](https://guides.hanamirb.org/v2.2/assets/overview/)
- [ROM (Ruby Object Mapper)](https://rom-rb.org/)
- [Puma Configuration](https://github.com/puma/puma#configuration)