supabase-rb-rb
Hotwire starter

Deployment

Production-readiness checklist for the Hotwire starter — secrets, SQLite vs Postgres, TLS, cache, health checks, observability.

This page covers the production-readiness concerns that aren't specific to your hosting provider. The kit ships a Dockerfile and a config/deploy.yml shaped for Kamal, but the same concerns apply if you're running on Fly, Render, Heroku, ECS, or anywhere else that boots a Linux container.

No per-target guide

This page is intentionally generic. The kit doesn't tie you to a particular host — Fly, Render, Heroku, Kamal-on-Hetzner, and ECS each have their own opinions about Dockerfiles, secrets, and health checks. The points below are the ones you have to think about regardless of which one you pick.

Secrets

Set these in your platform's secret manager. The kit's .env.example is for local development — Rails doesn't auto-load .env even in development (you export with direnv, dotenv, or your process manager), and in production a .env file should never ship.

VariableRequired?Set to
SUPABASE_URLrequiredYour project URL (e.g. https://abc.supabase.co). The gem reads it at boot.
SUPABASE_ANON_KEYrequiredThe anon / publishable key from the Supabase dashboard. Used by the gem for user-scoped requests (sign-in, sign-up, password reset, OAuth).
SUPABASE_SERVICE_ROLE_KEYrequired for account deletion / admin callsThe service_role key. Server-only — never expose to browsers.
RAILS_MASTER_KEYrequiredDecrypts config/credentials.yml.enc. Generate with bin/rails credentials:edit if you don't already have one.
RAILS_ENVrequiredproduction.
RAILS_LOG_LEVELoptionalinfo (default) or debug while bringing up a deploy.
PORT, WEB_CONCURRENCY, RAILS_MAX_THREADSoptionalPuma tuning — defaults in config/puma.rb. RAILS_MAX_THREADS doubles as the connection-pool size in config/database.yml.
GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_CLIENT_SECRETdocumentation-onlyThese are pasted into the Supabase dashboard, not read by Rails — they live in .env.example to make sharing values across environments easy.

The kit doesn't fail-fast on missing Supabase env vars in production — supabase-rails will surface its own actionable error message at first request if the URL or anon key is missing. If you want a louder boot-time check, add one to config/initializers/supabase.rb.

Database

The kit defaults to SQLite for all four roles — primary (app data), cache (Solid Cache), queue (Solid Queue), cable (Solid Cable). For a single-server Kamal deploy with persistent disk, that's a perfectly viable production posture; for multi-server deploys, you'll want Postgres for primary (and probably for the Solid roles too).

Choices to make:

  • SQLite (default). Mount storage/ as a persistent Docker volume so the SQLite files survive container restarts. config/deploy.yml declares this volume. Back it up the way you'd back up any single-server datastore (LiteFS, Litestream, or filesystem snapshots).
  • Postgres. Swap adapter: sqlite3 for adapter: postgresql in config/database.yml, add gem "pg" to the Gemfile, and set DATABASE_URL. For Solid Cache/Queue/Cable on Postgres, point each role at the same database with migrations_paths: separating them — or run a separate Postgres for each, depending on traffic.
  • Domain data vs Supabase Postgres. Remember the kit's split: auth.users and public.notes live in Supabase Postgres, not in the Rails-owned DB. Your AR models live in primary (SQLite or Postgres of your choice). Per-request RLS-scoped calls keep going to Supabase via PostgREST.
  • Migrations. Run migrations once per deploy, before the new image takes traffic. With Kamal, add a pre-deploy hook in config/deploy.yml. With most other platforms, a "release" or "pre-deploy" hook is the right place.

Sessions

The encrypted sb-session cookie is signed and encrypted with Rails' message verifier. The key comes from Rails.application.secret_key_base, which the gem reads at boot.

Two things to confirm before going live:

  • secret_key_base is stable across deploys. It's read from config/credentials.yml.enc (decrypted with RAILS_MASTER_KEY) or directly from the SECRET_KEY_BASE env var. If it rotates between deploys, every signed-in user is signed out at the rotation — their old sb-session cookies become undecryptable.
  • Cookie domain. The kit relies on the default — the cookie scopes to the request host. If you serve the app from multiple subdomains, set Rails.application.config.supabase.session = { ..., domain: ".example.com" } in config/initializers/supabase.rb and the cookie will work across them.

TLS

The kit's Dockerfile exposes port 3000, and config/deploy.yml's proxy.app_port: 3000 forwards from the Kamal proxy. Two settings to flip in production:

# config/environments/production.rb
config.assume_ssl = true   # we're behind a TLS-terminating proxy
config.force_ssl = true    # redirect HTTP → HTTPS and use Strict-Transport-Security

Both are commented out by default — uncomment them once your TLS terminator is live. With Kamal, that's the moment you flip proxy.ssl: true in config/deploy.yml (after the DNS A/AAAA record points at the host so Let's Encrypt can issue a cert).

The sb-session cookie ships with Secure: nil (auto-detect) in the gem's defaults — once assume_ssl = true, the cookie is marked Secure automatically and never leaks over HTTP.

If you're behind a load balancer or CDN (Cloudflare, ALB, Cloud Run), confirm the proxy forwards X-Forwarded-Proto so assume_ssl recognises the original scheme.

Cache

The cache role in config/database.yml points Rails.cache at Solid Cache by default (config.cache_store = :solid_cache_store in production). Solid Cache is shared across Puma workers and containers (it's a database) — anything you put in Rails.cache (HTTP fragment caches, etag caches, your own application caches) works correctly out of the box on a multi-worker deploy.

If you swap Rails.cache for :memory_store, every Puma worker has its own in-process cache and you lose the cross-worker invariant. Don't.

Third-party CDN pins

The kit's Importmap pins several Railsblocks dependencies to CDN URLs (Shoelace, Tom-Select, Air-Datepicker, PhotoSwipe, Embla, Motion). In development that's fine; in production you have two failure modes to think about:

  • CDN outage. A jsdelivr or esm.sh blip means your JS partially loads. Mitigate by vendoring: bin/importmap pin <pkg> --download pulls the file into vendor/javascript/ and updates config/importmap.rb to serve it locally. Then commit the change.
  • CSP. If you add a strict Content Security Policy, you'll need to allow cdn.jsdelivr.net and esm.sh as script sources (or vendor as above). The kit's config/initializers/content_security_policy.rb is the Rails default — empty.

Health checks

The kit ships GET /up — Rails' default health check. It returns 200 if the app booted and 500 otherwise. Wire your load balancer's health check at /up (the Kamal proxy uses /up out of the box).

Note: config.silence_healthcheck_path = "/up" in production silences the /up line from access logs. If you also want /up to verify the database, replace it with a controller that runs ActiveRecord::Base.connection.execute("SELECT 1") — but be aware that turns the LB health check into a DB heartbeat, which can take a healthy host down on a transient DB blip.

Observability

The kit ships logs to STDOUT with :request_id tags — that's the floor. Add the layers you need:

  • Error tracking. Sentry, Honeybadger, Bugsnag — all install as a gem and a single initializer. Wire it before you have your first 500.
  • APM / tracing. Datadog, New Relic, OpenTelemetry — same shape. Particularly useful here because most of the latency on Hotwire pages is Supabase PostgREST round-trips, which trace cleanly.
  • Log aggregation. Whatever ingests STDOUT works (Better Stack, Papertrail, Datadog Logs, etc.). Make sure you keep request_id end-to-end if you also instrument the browser.

CI

bin/ci runs the full quality gate locally — wire the same checks into your CI before letting a branch merge:

bin/rubocop                       # style
bin/brakeman --quiet --no-pager   # static security analysis
bin/bundler-audit                 # vulnerable-gem audit
bin/rails test                    # Minitest suite
bin/rails test:system             # Capybara + headless Chrome system tests
bin/e2e                           # end-to-end against live local Supabase

bin/ci chains all of the above and automatically skips bin/e2e if Docker isn't running or SKIP_E2E=1 is set. Neither case fails the run — you get a notice and move on. On hosted CI runners without Docker, set SKIP_E2E=1 so the rest of the suite still gates the PR.

Production readiness checklist

Walk this before your first cut to production:

  • SUPABASE_URL, SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY set in the production environment.
  • RAILS_MASTER_KEY set (and secret_key_base stable across deploys — no rotation between releases).
  • Database choice made — SQLite with a persistent volume, or Postgres with DATABASE_URL and gem "pg".
  • supabase/migrations/ applied against the production Supabase project (supabase db push after supabase link, or paste through the dashboard SQL editor).
  • config.force_ssl = true and config.assume_ssl = true uncommented in config/environments/production.rb.
  • proxy.ssl: true and proxy.host set in config/deploy.yml (if using Kamal).
  • CDN-pinned Railsblocks JS either vendored (bin/importmap pin … --download) or allowed by your CSP / accepted as a third-party dependency.
  • Rails.cache points at a shared store — :solid_cache_store (the kit default), Redis, or another shared cache.
  • Health check path on the LB is /up.
  • Rails-side migrations run before traffic flips (Kamal pre-deploy hook or platform equivalent).
  • Error tracking and log aggregation wired up.
  • bin/ci is green in CI on every PR.

When all of the above is true, the kit is ready for production traffic. Anything provider-specific (Fly/Render/Heroku/ECS/etc.) goes on top — the host concerns are local to that platform and not in scope for this guide.

On this page