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.
A guide to the failures you're most likely to hit on the path from git clone to a working request. Each section names the symptom you'll see and the fix.
"Missing required Supabase environment variable(s)" on boot
The initializer in config/initializers/supabase.rb fails fast if SUPABASE_URL, SUPABASE_ANON_KEY, or SUPABASE_JWT_SECRET is missing in production. In development it warns and continues.
- In dev: copy
.env.exampleto.envand fill in the four Supabase values (see Getting started). Restartbin/rails s—dotenv-railsreads.envat boot, not at request time. - In production: set the same three vars through your deploy platform's secret manager.
.envis not loaded in production. The error message names the missing variable(s) explicitly.
Spec suite won't start
LoadError: cannot load such file -- jwt— bundle install didn't finish or you're using the wrong Ruby. Re-run bundle install after confirming ruby --version matches .ruby-version (4.0.x for Rails 8.1).
ActiveRecord::NoDatabaseError— db:test:prepare hasn't run. bundle exec rspec will trigger it automatically; if it fails, run bin/rails db:prepare RAILS_ENV=test and check Postgres is up.
If the suite hangs or makes network calls, you've broken the in-memory JWKS — most likely by editing spec/rails_helper.rb and removing the ENV['SUPABASE_JWKS'] setup. Restore it; the in-memory JWKS is what makes the suite hermetic.
GET /api/v1/me returns 401 with a valid-looking token
The middleware accepts a JWT only if signature, audience, issuer, and expiry all check out. The body shape ({"error":"unauthorized"}) doesn't tell you which one failed, so add RAILS_LOG_LEVEL=debug and look at the log line from Supabase::Rails::Middleware. Common causes:
- Wrong
SUPABASE_JWT_SECRET. Local CLI prints a long static secret; Cloud projects have a per-project one in Project Settings → API → JWT Settings. Production env must match production JWTs. - Wrong project. If
SUPABASE_URLpoints at one project and the token was minted by another, theissclaim won't match and verification fails. - Expired token.
supabase-jsrefreshes automatically; a hand-copied token from a debug script expires in an hour. - Old anon-key format. A JWT-shaped
eyJ...anon key works as bothSUPABASE_ANON_KEYand (effectively)SUPABASE_PUBLISHABLE_KEY. Newsb_publishable_...keys are not JWTs — the kit handles either, but if you mixed an old project's secret with a new project's anon key, the validation can be inconsistent.
GET /api/v1/me returns 401 with no token at all (expected) — but the body is wrong
If you see {"message":"...","code":"..."} instead of {"error":"unauthorized"}, the JsonUnauthorizedResponder middleware isn't in the stack. Two likely causes:
- You edited
config/application.rband broke thestarter_kit.json_unauthorized_responderinitializer. Confirm it's still there and referencesSupabase::Rails::Middleware(which only exists after the gem's own initializer, henceafter: "supabase.middleware"). - You removed the
JsonUnauthorizedResponderRack class fromapp/middleware/. The class needs to exist on disk and be autoload-resolvable —config.autoload_paths << Rails.root.join("app/middleware")inconfig/application.rbis what makes that work.
Run bin/rails middleware to see the live stack. JsonUnauthorizedResponder should appear immediately above Supabase::Rails::Middleware.
"CORS error" in the browser, but curl works
Rack::Cors only mounts if CORS_ORIGINS is set (or you're in dev/test). In production with CORS_ORIGINS empty, the middleware doesn't mount at all and every browser request gets blocked by the browser before it reaches Rails. Symptoms:
- Browser dev tools show "Access to fetch at … from origin … has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header".
curlworks fine because curl doesn't enforce CORS.
Fix: set CORS_ORIGINS=https://your-frontend.example.com in the deploy environment and redeploy. Multiple origins go comma-separated. Don't use * in production unless you've thought about it.
Postgres connection refused
PG::ConnectionBad: connection to server ... failed: Connection refused- Local CLI: the Supabase CLI's Postgres isn't running.
supabase startit. The CLI prints the rightDATABASE_URL— copy it into.envexactly (port54322, not the default5432). - Local non-CLI: your host Postgres isn't listening. Start it (
brew services start postgresql,pg_ctl, etc.) and confirm the user/password matchconfig/database.ymlorDATABASE_URL. - Production / Supabase Cloud: make sure
DATABASE_URLpoints at the pooler URL (port 6543), not the direct connection (5432). The direct connection is rate-limited and not meant for app traffic.
/api-docs returns 404 in production
By default, rswag-ui is only mounted in production if SWAGGER_UI_ENABLED=true. The route table in config/routes.rb short-circuits the mount when both conditions are false. Set the env var to true and redeploy.
If you've set it and still get 404, the YAML file at swagger/v1/swagger.yaml may be missing from the production build. Confirm swagger/ is checked in (it should be) and not in .dockerignore.
swagger/v1/swagger.yaml is stale after a change
The OpenAPI doc is regenerated from spec/integration/ specs only on demand:
RAILS_ENV=test bundle exec rake rswag:specs:swaggerizeThe RAILS_ENV=test is required — rswag-specs is in the :test Bundler group, so the task isn't registered in development. Commit the regenerated YAML.
If the task says Task 'rswag:specs:swaggerize' not found, you forgot the RAILS_ENV=test. If it generates but the doc doesn't show your changes, the spec under spec/integration/ doesn't actually describe them — rswag only serializes what the DSL declares.
Rack::Attack is throttling me in development
It shouldn't be — Rack::Attack.enabled = false is set in the test env (config/initializers/rack_attack.rb). Development has the throttles on, with limits that are generous for a single user (300 req per 5 min per IP).
If you're getting throttled while iterating, either bump the limits in that initializer or temporarily set Rack::Attack.enabled = false in development.rb.
Rate limit isn't enforced in production
You set Rack::Attack to count per-IP, but the limit doesn't kick in. Almost always: Rails.cache is process-local in production, so each Puma worker has its own counter.
Confirm config.cache_store in production.rb is :solid_cache_store, :redis_cache_store, or :mem_cache_store — anything shared. The kit defaults to :solid_cache_store and it works out of the box, but a deploy that swapped it for :memory_store will silently fail-open.
Boot warning: apikey middleware not configured
Supabase::Rails::Middleware looks for SUPABASE_PUBLISHABLE_KEY. The kit's config/initializers/supabase.rb maps SUPABASE_ANON_KEY onto it at boot:
ENV["SUPABASE_PUBLISHABLE_KEY"] ||= ENV["SUPABASE_ANON_KEY"]If you removed that line or both vars are empty, the apikey middleware won't recognise the inbound apikey header. Set SUPABASE_ANON_KEY (the canonical kit env var) and the mapping does the rest. Newer Supabase projects label the same value sb_publishable_...; either format works.
Health check is failing the LB
The Kamal proxy (and most LBs) hit GET /healthz. The controller returns {"status":"ok"} with 200 and doesn't touch the database. If it's failing:
- 404: check
config/routes.rbstill hasget "healthz", to: "healthz#show". - 500: read the Rails log —
HealthzController#showis two lines, so a 500 usually means a Rack-level boot error.bin/rails middlewarewill tell you if the stack is broken. - 401: you accidentally removed
allow_unauthenticated_access only: :showfromHealthzController. Add it back; without it, theAuthenticationconcern will demand a token.
Deployment
Production-readiness checklist for the Rails API starter — secrets, TLS, rate-limit cache, health checks, observability.
Hotwire starter
Rails 8.1 server-rendered starter kit with Hotwire (Turbo + Stimulus), ViewComponent, Tailwind v4, and Supabase Auth via supabase-rails in :web mode with encrypted cookie sessions.