Deployment
Production-readiness checklist for the Inertia + React starter — secrets, SSR build flag, database, TLS, cache, health checks, observability.
This page covers 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 (export with direnv, dotenv, or your process manager), and in production a .env file should never ship.
| Variable | Required? | Set to |
|---|---|---|
SUPABASE_URL | required | Your project URL (e.g. https://abc.supabase.co). The gem reads it at boot. |
SUPABASE_PUBLISHABLE_KEY | required | The publishable key (sb_publishable_...). Used for user-scoped requests and as the Realtime bootstrap key. Safe to expose to browsers. |
SUPABASE_SECRET_KEY | required for account deletion / admin calls | The secret key (sb_secret_...). Server-only — never expose to browsers. |
SUPABASE_JWKS_URL | required | <SUPABASE_URL>/auth/v1/.well-known/jwks.json — the gem fetches the JWKS at boot to verify session tokens. |
RAILS_MASTER_KEY | required | Decrypts config/credentials.yml.enc. Generate with bin/rails credentials:edit if you don't already have one. |
RAILS_ENV | required | production. |
RAILS_LOG_LEVEL | optional | info (default) or debug while bringing up a deploy. |
PORT, WEB_CONCURRENCY, RAILS_MAX_THREADS | optional | Puma tuning — defaults in config/puma.rb. RAILS_MAX_THREADS doubles as the connection-pool size in config/database.yml. |
SOLID_QUEUE_IN_PUMA | optional | true runs Solid Queue inside Puma — fine on a single server, switch to a dedicated bin/jobs host when traffic grows. |
The kit doesn't fail-fast on missing Supabase env vars in production — supabase-rails will surface its own actionable error at first request if any are missing. If you want a louder boot-time check, add one to config/initializers/supabase.rb.
SSR build flag
Inertia SSR is wired but disabled by default. To ship SSR:
- Set
config.ssr_enabled = trueinconfig/initializers/inertia_rails.rb. - Build the image with
--build-arg SSR_ENABLED=trueso the SSR bundle ships inpublic/vite-ssr/ssr.jsand Node 22 stays in the final image.
With Kamal
Add to config/deploy.yml:
builder:
args:
SSR_ENABLED: trueBy hand
docker build --build-arg SSR_ENABLED=true -t react_starter_kit .When SSR_ENABLED=false (the default), the Dockerfile's branch-ssr-false stage skips the vite build --ssr step and the final image drops Node entirely — smaller image, faster cold boots. Flip the flag the day SEO or first-paint pressure makes SSR worthwhile.
If the SSR process crashes or times out at runtime, Inertia falls back to client-side rendering (config.on_ssr_error). The fallback is silent — wire an APM or log alert so you find out before the SEO impact does.
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.ymldeclares this volume. Back it up the way you'd back up any single-server datastore (LiteFS, Litestream, or filesystem snapshots). - Postgres. Swap
adapter: sqlite3foradapter: postgresqlinconfig/database.yml, addgem "pg"to the Gemfile, and setDATABASE_URL. For Solid Cache/Queue/Cable on Postgres, point each role at the same database withmigrations_paths:separating them. - Domain data vs Supabase Postgres. Remember the kit's split:
auth.userslives in Supabase Postgres. Anything you add through the per-request RLS-scoped client (see Customization → Recipe 1) also lives in Supabase. Your ActiveRecord models live inprimary(SQLite or Postgres of your choice). - Migrations. Run Rails migrations once per deploy, before the new image takes traffic. With Kamal, add a
pre-deployhook inconfig/deploy.yml. Supabase migrations (supabase/migrations/) are a separate flow —supabase db pushagainst a linked project, or apply through the dashboard.
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_baseis stable across deploys. It's read fromconfig/credentials.yml.enc(decrypted withRAILS_MASTER_KEY) or directly from theSECRET_KEY_BASEenv var. If it rotates between deploys, every signed-in user is signed out at the rotation — their oldsb-sessioncookies 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" }inconfig/initializers/supabase.rband the cookie will work across them.
TLS
The kit's Dockerfile exposes port 80 (via Thruster) and config/deploy.yml's proxy.app_port: 80 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 emit HSTSBoth 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.
Vite + asset pipeline
bin/rails assets:precompile runs as part of the Docker build. It calls npx vite build (and npx vite build --ssr if SSR_ENABLED=true), writes the digested client bundle to public/vite/, and emits public/vite/manifest.json. The <%= vite_tags … %> helper reads that manifest at runtime to emit the correct <script> and <link> tags.
Two things to keep in mind:
- The manifest is the source of truth. If a deploy ships without
public/vite/manifest.json(e.g.assets:precompilewas skipped), every page will 500 withRailsVite::ManifestMissing. TheDockerfileruns precompile before the final image stage — don't remove it. - The client bundle is digested. Filenames are content-hashed, so the browser cache never serves a stale asset across deploys. Set
Cache-Control: public, max-age=31536000, immutablefor/vite/*at your CDN / proxy layer — Thruster does this by default.
After Vite finishes, the build deletes node_modules/ from the image (RUN ... && rm -rf node_modules). The final image only ships the JS runtime (Node 22) when SSR_ENABLED=true, and ships no Node at all otherwise — small and quick to boot.
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).
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 Inertia pages is the round-trip from React → Rails → Supabase, which traces cleanly when each hop is instrumented.
- Frontend error tracking. Sentry (and others) ship browser SDKs that capture React render errors and unhandled promise rejections. Mount it from
entrypoints/inertia.tsxbeforecreateInertiaApp. - Log aggregation. Whatever ingests STDOUT works (Better Stack, Papertrail, Datadog Logs, etc.). Keep
request_idend-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/setup --skip-server # bundle + npm install + db:prepare
bin/rubocop # Ruby style
npm run lint # eslint over app/javascript
npm run format # prettier check
npm run check # tsc — both app and node tsconfigs
bin/bundler-audit # vulnerable-gem audit
npm audit # vulnerable-npm audit
bin/brakeman --quiet --no-pager --exit-on-warn # Rails static security analysis
bin/rspec # request specs
env RAILS_ENV=test bin/rails db:seed:replant # seeds re-runnablebin/ci chains all of the above. On hosted CI, no extra Docker is required — there's no bin/e2e equivalent in this kit, so the suite runs fully in-container.
Production readiness checklist
Walk this before your first cut to production:
-
SUPABASE_URL,SUPABASE_PUBLISHABLE_KEY,SUPABASE_SECRET_KEY, andSUPABASE_JWKS_URLset in the production environment. -
RAILS_MASTER_KEYset (andsecret_key_basestable across deploys — no rotation between releases). - Database choice made — SQLite with a persistent volume, or Postgres with
DATABASE_URLandgem "pg". -
supabase/migrations/(if you have any) applied against the production Supabase project (supabase db pushaftersupabase link, or paste through the dashboard SQL editor). -
config.force_ssl = trueandconfig.assume_ssl = trueuncommented inconfig/environments/production.rb. -
proxy.ssl: trueandproxy.hostset inconfig/deploy.yml(if using Kamal). -
Rails.cachepoints at a shared store —:solid_cache_store(the kit default), Redis, or another shared cache. - SSR decision made —
ssr_enabledflipped and--build-arg SSR_ENABLED=trueset together, or both left off. - Vite bundle precompiled in the image (
bin/rails assets:precompileruns in the build stage — don't skip). - Health check path on the LB is
/up. - Rails-side migrations run before traffic flips (Kamal
pre-deployhook or platform equivalent). - Error tracking (server + browser) 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
Inertia + React recipes for extending the starter — add a new Inertia page, add a shadcn component, and wire a Supabase Realtime channel into a React component.
Troubleshooting
Most likely first-run failures with the Inertia + React starter — Vite not serving, cookie not set, typed routes stale, shadcn add fails, SSR fallback loops — and how to resolve them.