Deployment
Production-readiness checklist for the Rails API starter — secrets, TLS, rate-limit 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 will 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. Don't ship .env to production — dotenv-rails only auto-loads in development and test, so they wouldn't be read anyway, but it's the kind of mistake that goes unnoticed until the secrets show up in a backup.
| Variable | Required? | Set to |
|---|---|---|
SUPABASE_URL | required | Your project URL (e.g. https://abc.supabase.co). Boot fails fast if missing in production. |
SUPABASE_ANON_KEY | required | The anon/publishable key from the Supabase dashboard. |
SUPABASE_JWT_SECRET | required | JWT signing secret from Project Settings → API. Treat this like any other production secret. |
DATABASE_URL | required | Production Postgres URL. For Supabase Cloud, use the pooled (6543) URL. |
CORS_ORIGINS | required if browser clients | Comma-separated list of allowed origins. If empty in production, Rack::Cors is not mounted at all — fail-closed. |
SWAGGER_UI_ENABLED | optional | true to expose /api-docs in production. Off by default. |
RAILS_MASTER_KEY | only if using encrypted credentials | Generated by bin/rails credentials:edit. |
SECRET_KEY_BASE is read from config/credentials.yml.enc (or RAILS_MASTER_KEY). If you don't use encrypted credentials, set SECRET_KEY_BASE directly in the environment.
Database
A few decisions that matter:
- Use the pooler. For Supabase Cloud, the pooler URL (port 6543) is what you want — it shares connections across containers so you don't blow through Postgres'
max_connectionsceiling. The direct URL (port 5432) is fine for one-offdb:migrate, not for the running web container. max_connectionsper container.config/database.ymlreadsRAILS_MAX_THREADS(default5) — set it to match your Pumathreadssetting so the pool size matches concurrency.- Run migrations once per deploy, before the new image takes traffic. With Kamal, hook this into
pre-deploy; with most other platforms, a "release" or "pre-deploy" hook is the right place. Backwards-compatible migrations are non-negotiable during a rolling deploy. - Solid Cache / Queue / Cable. Production runs on the three additional schemas defined in
config/database.yml.bin/rails db:preparesets them up; the schemas are static (no migrations).
Rate limiting in production
Rack::Attack's throttles count requests in Rails.cache. In development that's an in-process memory store, which is fine. In production with multiple Puma workers and multiple containers, the cache must be shared — otherwise each worker has its own counter and a limit: 300/5min becomes 300 × workers × containers / 5min.
The kit's production config defaults to Solid Cache (config.cache_store = :solid_cache_store), which is shared via Postgres — so the throttles work correctly out of the box. If you switch to a different cache store, make sure it's a shared one: Redis, Memcached, or another Solid Cache database. Don't point Rails.cache at :memory_store in production.
The throttled response is 429 {"error":"too_many_requests"} with a Retry-After header. Clients should honour it; servers should not retry on 429 with backoff disabled.
CORS
Rack::Cors is mounted only if CORS_ORIGINS is set in production. The default (CORS_ORIGINS=*) is not applied in production — set it explicitly:
CORS_ORIGINS=https://app.example.com,https://admin.example.comIf your API is only ever called server-to-server (mobile/SPA → API, never browser → API), leave CORS_ORIGINS empty and the middleware never mounts. That's the most secure posture.
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-SecurityBoth are commented out by default — uncomment them once your TLS terminator is live. With Kamal, that's the moment you flip proxy.ssl: true (after the DNS A/AAAA record points at the host so Let's Encrypt can issue a cert).
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.
Health checks
Two endpoints, two jobs:
| Path | Purpose | Who reads it |
|---|---|---|
GET /healthz | Liveness — returns 200 if the app is responding. Custom JSON body {"status":"ok"}. | Load balancers, the Kamal proxy. |
GET /up | Rails' default health check — 200 if the app booted, 500 otherwise. | Heroku, Render, ECS, anything that expects the Rails default. |
Wire your load balancer's health check at /healthz (the kit's default; see proxy.healthcheck.path in config/deploy.yml). Neither endpoint hits the database — they're cheap.
Note: config.silence_healthcheck_path = "/up" in production silences /up from the logs but not /healthz. If your LB hammers /healthz and floods your logs, add it to the silence list too.
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 auth latency is JWKS verification and Postgres round-trips, both of which trace nicely.
- Log aggregation. Whatever ingests STDOUT works (Better Stack, Papertrail, Datadog Logs, etc.). Make sure you keep
request_idend-to-end if you also instrument the client.
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 -q # static security analysis
bin/bundler-audit # vulnerable-gem audit
bundle exec rspec # RSpec suite
bin/rails test # Minitest infrastructure suiteOr run them all together with bin/ci. None of them need a running Supabase project — the spec suite uses an in-memory JWKS keyed by SUPABASE_JWT_SECRET, and the Minitest suite covers middleware in isolation.
Production readiness checklist
Walk this before your first cut to production:
- All four
SUPABASE_*andDATABASE_URLsecrets set in the production environment. -
DATABASE_URLuses the pooler URL (Supabase Cloud) or matches your Postgres connection-pooling story. -
CORS_ORIGINSset explicitly — or confirmed empty because there's no browser client. -
config.force_ssl = trueandconfig.assume_ssl = trueuncommented inconfig/environments/production.rb. -
SWAGGER_UI_ENABLEDdecided — usually off in production for closed APIs. -
Rails.cachepoints at a shared store (Solid Cache, Redis, Memcached) soRack::Attackthrottles count correctly across containers. - Health check path on the LB is
/healthz. - Migrations run before traffic flips (Kamal
pre-deployhook or platform equivalent). - Error tracking and log aggregation wired up.
-
bin/ciis 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.
Customization
Concrete recipes for extending the Rails API starter — add a resource, allow an unauthenticated route, change the database schema.
Troubleshooting
Most likely first-run failures with the Rails API starter — 401 loops, JWKS errors, CORS preflight, Postgres connection refused, missing env, swagger regeneration — and how to resolve them.