Diff v2 → v3
v2: bot legacy · 2026-07-23T08:15:50Z
v3: bot legacy · 2026-08-13T07:10:22Z
# Rails Production Playbook A short, opinionated reference for building serious Rails apps. The **principles** are language-agnostic — anyone can lift them — but the **gems and snippets** are Rails-first. Default posture: *boring, explicit, deny-by-default, observable.* > Rule of thumb: reach for a pattern when the pain it removes is real, not > hypothetical. Every item below has a "use when" so you don't over-engineer. --- ## Architecture & code design ### Sandi Metz discipline — lean controllers, service objects, enums Keep classes small and single-purpose. Controllers only orchestrate: authenticate, authorize, call one object, render. Push logic into POROs ("service objects"). - **Guidelines:** ≤100 lines/class, ≤5 lines/method, ≤4 params, one job per object. - **Gems:** plain Ruby first; `interactor` or `dry-monads` if you want a Result type. - **Use when:** always for controllers; extract a service the moment an action does more than one thing or touches more than one model. - Use Rails `enum` for finite states that are *attributes*; use a **state machine** (below) when transitions have rules. ### Service objects + Result types (dry-monads / dry-contracts) Make success/failure explicit instead of raising for control flow. - **Gems:** `dry-monads` (`Success`/`Failure`, `Do` notation), `dry-validation` / `dry-contracts` (typed, composable input validation at the boundary). - **Use when:** multi-step operations that can fail in known ways (payments, imports, signups). Validate untrusted input with a contract *before* the service. ```ruby class CreateInvoice include Dry::Monads[:result, :do] def call(params) attrs = yield InvoiceContract.new.(params).to_monad # Failure stops the chain invoice = yield persist(attrs) Success(invoice) end end ``` ### Slice / vertical-feature development Organize by **feature**, not by layer. A change should touch one slice front-to-back rather than smearing across `app/*`. Enforce boundaries in a modular monolith with **Packwerk** (`packs/`), split to services only when a slice needs independent scaling or deploys. - **Use when:** the app outgrows "one big `app/`" and teams step on each other. ### EventBus / pub-sub Decouple side effects (emails, audit, search reindex) from the core write. - **In-process, cheap:** `ActiveSupport::Notifications` or `wisper`. - **Durable / event-sourced:** `rails_event_store`. - **Use when:** one action fans out to ≥2 unrelated reactions. Keep the *write* transactional; emit events `after_commit` so listeners never see rolled-back data. ### Enums — named values, never magic strings Raw strings scattered through code (`"pending"`, `"active"`, `where(status: "shipped")`) are typo-prone and have no single source of truth. Centralize the allowed values. - **Rails `enum`:** maps a column to named values; gives you scopes (`Order.shipped`), predicates (`order.shipped?`), and setters (`order.shipped!`). Define the mapping **explicitly** so values are stable — never rely on positional integers, and prefer a string-backed column for readability in the DB. - **Richer enums (behavior/metadata/i18n per value):** `enumerize`, or a small PORO registry / value object when each value carries logic. Translate display labels via i18n — don't hardcode human strings either. - **Use when:** any attribute drawn from a fixed, known set (status, role, kind, priority). One definition, referenced everywhere. ```ruby class Order < ApplicationRecord enum :status, { draft: "draft", paid: "paid", shipped: "shipped" }, default: :draft # → Order.paid, order.paid?, order.shipped!, Order.statuses end ``` - **Relationship to state machines:** an `enum` lists the *values*; a **state machine** governs the *transitions* between them. If moving between values has rules, guards, or callbacks (`draft → paid → shipped`, never `draft → shipped`), let the state machine own the column — it becomes the single source of truth for both the allowed states *and* the legal moves. Plain `enum` is the right tool only when any value can follow any other. ### State machines (AASM) Model lifecycles with explicit, guarded transitions and callbacks. - **Gems:** `aasm` (attribute-based, ergonomic — status lives on the model); `statesman` (Gusto — stores each transition as a row in its own table, giving a built-in audit trail and history, at the cost of more setup); `state_machines-activerecord` (another attribute-based option). - **Pick:** `aasm` for simple lifecycles on the record; `statesman` when you need the full transition history / audit of *how* it got to a state. - **Use when:** an object moves through statuses with rules (`draft → published → archived`), not just a flat enum. --- ## Data & persistence ### IDs: bigint internal, UUID external, friendly_id for URLs - **`bigint`** primary keys for internal joins (fast, compact). - **`uuid`** (a separate `public_id`) for anything exposed in APIs/URLs — never leak sequential counts or enable ID enumeration. - **`friendly_id`** for human-readable slugs (`/users/jane-doe`, `/articles/the-title`). - **Use when:** any app with a public surface. Add the UUID column from day one. ### Multi-tenancy: RLS / ActsAsTenant as first class Make tenant isolation a property of the data layer, not something each query remembers to add. - **`acts_as_tenant`:** sets a current tenant and auto-scopes every query. - **Postgres RLS (row-level security):** the strongest option — the database itself refuses cross-tenant rows even if app code forgets. Belt *and* suspenders. - **Use when:** B2B / SaaS with shared tables. Decide *before* writing schema; retrofitting tenancy is painful. If you don't need tenancy, don't add it. ```sql ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON invoices USING (account_id = current_setting('app.current_account_id')::bigint); ``` + #### Two database roles: `app` (restricted) and `migrator` (owner) + RLS is only as strong as the role that connects. **A table's owner, any + `SUPERUSER`, and any role with `BYPASSRLS` silently ignore every policy** — so if + your app connects as the same role that ran the migrations, RLS is decoration. + Split the roles: + + - **`myapp_migrator`** — owns the schema, runs migrations, creates policies. + Used by `rails db:migrate` / deploy tasks only. Never by the running app. + - **`myapp_app`** — owns nothing, `NOSUPERUSER NOBYPASSRLS`, holds only + `SELECT/INSERT/UPDATE/DELETE` on tables plus `USAGE` on sequences. This is the + role Puma and Sidekiq connect as. Policies actually apply to it. + + ```sql + CREATE ROLE myapp_migrator LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS; + CREATE ROLE myapp_app LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS; + + -- migrator owns the schema; app only uses it + GRANT USAGE ON SCHEMA public TO myapp_app; + ALTER DEFAULT PRIVILEGES FOR ROLE myapp_migrator IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO myapp_app; + ALTER DEFAULT PRIVILEGES FOR ROLE myapp_migrator IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO myapp_app; + + -- belt: FORCE makes the policy apply even to the table owner + ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; + ALTER TABLE invoices FORCE ROW LEVEL SECURITY; + ``` + Add `FORCE ROW LEVEL SECURITY` anyway — it closes the hole if someone ever points + the app at the owning role by mistake. + + **Wire it in Rails.** Two URLs, one per role; migrations use the privileged one: + ```yaml + # config/database.yml + default: &default + adapter: postgresql + url: <%= ENV["DATABASE_URL"] %> # myapp_app — runtime + migrations_paths: db/migrate + + production: + primary: + <<: *default + primary_migrator: # rails db:migrate uses this + <<: *default + url: <%= ENV["MIGRATOR_DATABASE_URL"] %> # myapp_migrator + migrations_paths: db/migrate + database_tasks: false + ``` + Then set the tenant per request, **transaction-scoped** (`set_config(..., true)`) + so a pooled connection can never leak one tenant's id into the next checkout: + ```ruby + class ApplicationRecord < ActiveRecord::Base + def self.with_tenant(account_id) + transaction do + connection.exec_query( + "SELECT set_config('app.current_account_id', $1, true)", + "set_tenant", [account_id.to_s] + ) + yield + end + end + end + ``` + + #### Use the restricted role in development too + The most common way RLS fails in production is that nobody ever exercised it. + Locally everyone connects as their own superuser (`postgres`, or the macOS + username Homebrew created), every policy is bypassed, and a query missing its + tenant scope looks perfectly fine — until prod, where it returns zero rows or, + worse, the policy was never right and prod is the first place it's tested. + + - **Dev and test connect as `myapp_app`, same as production.** Create the role in + `bin/setup` / a seed task so a fresh clone gets it automatically. + - **Only `db:migrate`, `db:prepare`, and `db:schema:load` use `myapp_migrator`** — + in every environment, so dev and prod fail the same way. + - **Payoff:** a forgotten `with_tenant` blows up on your laptop, in a failing + spec, instead of in an incident. Write a spec that connects without a tenant set + and asserts the query returns **no rows** — that test is the real proof RLS is on. + - **Gotcha:** if dev used to run as owner/superuser, expect a wave of permission + errors the first time you switch (missing `GRANT`s on new tables, sequences, + extensions). That's the point — those same gaps were latent in prod. + ### JSONB for metadata Use a `jsonb` column for sparse, schemaless, or fast-evolving attributes — *not* as an excuse to avoid real columns for things you query or index heavily. - **Rails:** `store_accessor :metadata, :referrer, :utm_source`; GIN-index when you query inside it. - **Use when:** flexible per-record extras (settings, integration payloads, feature config). Promote a key to a real column once you filter/sort on it. ### Transactions — when & how Wrap writes that must succeed or fail **together**. - **Do:** group related `INSERT`/`UPDATE`s; use DB constraints as the real guarantee; put side effects in `after_commit`, never mid-transaction. - **Don't:** make HTTP/network calls inside a transaction (holds locks, can't roll back the remote side); don't span a transaction across user think-time. - Use advisory locks / `with_lock` for "check-then-act"; pick an isolation level deliberately when you have read-modify-write races. ### Auditing (paper_trail / audited / logidze) Track who changed what, when — for compliance, debugging, and undo. - **`paper_trail`:** version history with whodunnit, restore previous versions. - **`audited`:** lighter change log. - **`logidze`:** Postgres-trigger based (no app-layer overhead). - **Use when:** regulated data, financial records, or anything users dispute. --- ## Security ### Policies, zero trust, deny-by-default Authorization is a first-class layer, not scattered `if current_user.admin?`. - **Gems:** `pundit` or `action_policy` (policy objects per resource); `rack-attack` (rate limit / block); `brakeman` + `bundler-audit` in CI. - **Posture:** **deny by default** — no policy means no access. Authorize every action; never trust client-supplied IDs, scopes, or roles. Strong params always. - **Use when:** always. Add a default `deny` and opt routes *in*. ### Secrets vs config — credentials vs ENV vs YAML Three buckets, don't mix them: - **`.yml`-backed config objects** for non-secret, environment-shaped values: URLs, database names, endpoints, timeouts, feature defaults. Gems: `config` (`Settings.x.y`) or `anyway_config`. - **Rails encrypted credentials** for *secrets*: DB passwords, integration API keys, signing keys (`rails credentials:edit`, committed encrypted). - **ENV** only for *runtime-start* values that differ per host/boot and aren't secret by nature (e.g. which credentials key to load, `RAILS_MAX_THREADS`). - **Rule:** if leaking it is a breach → credentials. If it just shapes the environment → yml. If the platform injects it at boot → ENV. + ### Static security analysis — one command, `bin/ci` + Security scanning that only runs on someone's laptop when they remember doesn't + run. Put every static check behind **one entrypoint** developers and CI both call, + so "did it pass?" has a single answer. + + Rails 8.1 ships this natively: `bin/ci` driven by a `config/ci.rb` DSL. On older + Rails, write `bin/ci` as a shell script that exits non-zero on the first failure — + the value is the single entrypoint, not the DSL. + ```ruby + # config/ci.rb + CI.run do + step "Setup", "bin/setup --skip-server" + step "Style: Ruby", "bin/rubocop" + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap audit", "bin/importmap audit" + step "Security: Brakeman", + "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + step "Tests: Rails", "bin/rails test" + step "Tests: System", "bin/rails test:system" + end + ``` + The GitHub Actions workflow then shrinks to one line — `run: bin/ci` — which means + CI can't drift from what you run locally. + + **What to put in it:** + - **`brakeman`** — Rails-aware SAST: SQL injection, mass assignment, unsafe + `render`/`redirect_to`, XSS, command injection, weak crypto. Run with + `--exit-on-warn`; triage real findings and check in `config/brakeman.ignore` + (with a reason per entry) rather than lowering confidence thresholds. + - **`bundler-audit`** — gem dependencies against the Ruby Advisory DB. + `bundle audit check --update`. + - **`ruby_audit`** — the same for the Ruby and RubyGems versions themselves, + which `bundler-audit` doesn't cover. + - **`bin/importmap audit`** — JS dependency CVEs when you use importmaps; use + `yarn npm audit` / `pnpm audit` if you bundle instead. + - **`rubocop`** + **`rubocop-rails`/`-rspec`/`-performance`** — style is not + security, but consistent code makes the security diffs readable. Add + **`erb_lint`** for template-level escaping issues. + - **Secret scanning:** `gitleaks` (or `trufflehog`) over the diff *and* the + history — a rotated key still in git history is still leaked. Pair with a + pre-commit hook so it never lands in the first place. + - **Workflow hardening:** `actionlint` + `zizmor` if you're on GitHub Actions — + CI itself is an attack surface (unpinned actions, `pull_request_target`, + injectable `${{ }}` expressions). + - **Dependabot / Renovate** for the *upgrade* side; the audit steps above are the + *gate*. You want both — one opens PRs, the other fails the build. + - **`semgrep`** when you need custom org rules ("never call this helper", + "no raw SQL outside `app/queries`") that Brakeman doesn't model. + + **Rules that keep it honest:** + - **Fail the build, don't warn.** A scanner whose output is advisory is ignored + within two sprints. + - **Every suppression carries a reason and an owner.** Ignore files are fine; + anonymous ignore files are technical debt with a security label. + - **Run it on the PR, and on a schedule.** New CVEs land against unchanged code — + a nightly `bin/ci` run catches what the PR gate structurally can't. + - **Keep it fast.** If `bin/ci` takes 20 minutes nobody runs it locally, and the + local run is the whole point. + - Static analysis finds *classes* of bugs, not your bugs. It does not replace + authorization policies, RLS, or a review of the actual threat model. + --- ## Reliability & resilience ### HTTP errors the RFC way (RFC 9457 `problem+json`) Return machine-readable errors with a stable shape, not ad-hoc JSON. - **Shape:** `type`, `title`, `status`, `detail`, `instance` (+ `errors`, `trace_id`). - **Rails:** `rescue_from` → render `application/problem+json`. No dedicated gem needed; a thin renderer is enough. - **Use when:** any API with external consumers (incl. your own SPA / agents). ```ruby rescue_from Pundit::NotAuthorizedError do |e| render json: { type: "/errors/forbidden", title: "Forbidden", status: 403, detail: e.message, trace_id: request.request_id }, status: :forbidden, content_type: "application/problem+json" end ``` ### Idempotency Make retried writes safe — same request, same result, no duplicates. - **How:** accept an `Idempotency-Key` header; store key + response; replay on repeat; reject body mismatch (`422`). Back it with a unique index. - **Gems:** roll your own table, or `sidekiq-unique-jobs` for job-level dedup. - **Use when:** payments, order creation, anything a client/network may retry. ### Circuit breakers Stop hammering a failing dependency; fail fast and recover automatically. - **Gems:** `stoplight` (general), `semian` (Shopify — for net/http, MySQL, Redis), `faraday-retry` for retries with backoff. - **Use when:** you call flaky third parties or internal services over the network. ### Feature flags Decouple deploy from release; kill-switch risky paths; gradual rollout. - **Gem:** `flipper` (+ `flipper-active_record`, `flipper-ui`). - **Use when:** trunk-based dev, canary releases, per-account betas, ops kill-switch. ### `/health` — real readiness, not just "the web server is up" Verify the whole boot: DB reachable, migrations current, Redis/queue up, required integrations reachable. Separate **liveness** (am I running?) from **readiness** (can I serve traffic?). - **Rails 7.1+:** `/up` exists but only proves the process booted — extend it. - **Gems:** `okcomputer` or `health_check` for multi-dependency checks. - **Use when:** any real deployment — load balancers and orchestrators gate on it. ### Versioning & releases Every deploy should carry a real, traceable version — not just a git SHA in your head. If you deploy with **Kamal** (Basecamp's own, very Rails-idiomatic tool), auto-bump a semantic version from your Conventional Commits and tag the release **only on a healthy deploy**, via `.kamal/hooks/` pre/post-deploy hooks. Full recipe: [[kamal-auto-version|Kamal Auto-Versioning]]. - **Tooling:** `svu` (Conventional Commits → semver: `feat:`→minor, `fix:`→patch, `!`/`BREAKING CHANGE:`→major), a `pre-deploy` hook to preview/guard, a `post-deploy` hook to `git tag` after success. - **Surface it:** expose the version + git SHA on your deep `/health` (above) so a running instance self-reports exactly what shipped — invaluable during an incident. (In Rails, read it from an ENV/initializer rather than the Go `ldflags` trick in the reference.) - **Use when:** any Kamal-deployed app — so every release is tagged in git and traceable. --- ## Scalability - **Vertical:** bigger box first — simplest win until a single node is maxed. - **Horizontal:** more app servers behind a balancer; tune **Puma** workers/threads and the DB **connection pool** together; move work to background jobs (`Sidekiq` / `GoodJob` / `Solid Queue`). - **Database:** read replicas + Rails multi-DB (`connects_to`), then sharding only when a single primary is the bottleneck. Cache aggressively (`Solid Cache`, Russian-doll fragment caching). - **Use when:** scale to the *next* bottleneck you can measure — not preemptively. --- ## Observability You can't fix what you can't see. Aim for the three pillars + errors. - **Logs:** structured/JSON (`lograge` + custom fields, request id, tenant id). - **Metrics:** `yabeda` → Prometheus → Grafana; instrument with `ActiveSupport::Notifications`. - **Traces:** OpenTelemetry (`opentelemetry-ruby`) across web → job → DB → HTTP. - **Errors:** Sentry / Honeybadger with release + user/tenant context. - **Use when:** before you need it. Wire request-id + tenant-id through every log line so an incident is greppable end-to-end. (Plug your own stack in here.) ### Correlation IDs — one thread through everything A single id that follows **one logical operation** end-to-end: inbound request → background jobs → outbound HTTP → emitted events → every log line. It turns "what actually happened to this request?" from archaeology into a single grep. - **Vs. request id:** a request/trace id is per-hop; a **correlation id** spans the whole business operation across processes and async boundaries. Often you carry both. - **How:** accept it at the edge (`X-Correlation-ID` header) or mint one; stash it in `ActiveSupport::CurrentAttributes` so any code can read it without threading it through every method; then propagate it deliberately — - into **every structured log line** (and your metrics/trace context), - echoed back in the response and in `problem+json` (`trace_id`), - copied into **background job** arguments/metadata (e.g. a Sidekiq client middleware), so async work keeps the same id, - forwarded as a header on **outbound** service calls, - attached to **emitted events** (see EventBus) so reactions stay traceable. - **Gems/Rails:** `ActiveSupport::CurrentAttributes`, `ActionDispatch::RequestId` (`X-Request-Id`); a small Sidekiq middleware pair to set/read it on jobs. - **Use when:** anything that crosses more than one process, job, or service — i.e. every non-trivial system. Pairs naturally with **idempotency** keys and the **EventBus**. --- ## Testing ### The suite (rspec, factories, fakes, end-to-end) - **`rspec-rails`** as the framework; table-driven where it fits. - **`factory_bot`** + **`faker`** for test data; prefer factories over fixtures. - **Fakes/stubs for I/O:** `webmock` / `vcr` for HTTP, in-memory fakes for adapters — never hit real third parties in tests. - **End-to-end user-story tests:** drive real flows (sign up → do the thing → see result). `capybara` + `cuprite`, or **Playwright via MCP** for browser-level - journeys an agent can run. + journeys an agent can run — same pass also yields the QoE numbers (Web Vitals, + console errors, page weight), which no request spec will ever give you. Recipe: + [QA and QoE Testing with Playwright MCP](https://wiki.joexbayer.dev/wiki/qa-and-qoe-testing-with-playwright-mcp); + the case for measuring it: [QA and QoE](https://wiki.joexbayer.dev/wiki/qa-and-qoe). - **Use when:** unit-test logic-heavy POROs; integration-test the slice; reserve a handful of slow E2E tests for critical revenue paths. ### Performance & N+1 testing Catch slow queries and fan-out in CI, not in production. - **Gems:** `prosopite` (N+1 detection — strict, low false positives; pair with `pg_query`), `bullet` (alternative), `test-prof` (`let_it_be`, factory profiling), `rspec-benchmark` / `derailed_benchmarks` for memory & boot. - **Use when:** any list/index endpoint, any view that loops over associations. Fail the build on a new N+1. --- ## User feedback Errors and guidance are product, not afterthoughts. - **Errors:** human-readable HTML pages for browsers, `problem+json` for APIs (above). Tell the user *what to do next*, surface a trace id for support. - **Flash / notifications:** `noticed` for multi-channel (in-app, email) notices. - **Onboarding / tutorials:** `shepherd.js` / `intro.js` product tours. - **Use when:** every user-facing failure and every first-run experience. --- ## Async & agentic engineering - **Background work:** `Sidekiq` / `GoodJob` / `Solid Queue` for anything slow, retryable, or fan-out. Jobs should be **idempotent** (see above) and small. - **Agentic subagents:** decompose AI work into bounded, single-purpose subagents with pre-fetched context; orchestrate sequentially or in parallel waves; verify outputs with an independent pass (trust-but-verify). Treat an LLM call like any flaky network dependency — wrap it (timeouts, retries, circuit breaker) and make the surrounding job idempotent. - **Use when:** long-running, parallelizable, or AI-driven work that shouldn't block the request cycle. --- ## Quick reference | Concern | Reach for | Use when | |---|---|---| | Multi-tenancy | `acts_as_tenant` + Postgres RLS | shared-table SaaS | + | DB roles | `app` (NOBYPASSRLS) vs `migrator` (owner) | any app using RLS — in dev too | | Authorization | `pundit` / `action_policy`, deny-by-default | always | + | Static analysis | `bin/ci` → `brakeman`, `bundler-audit`, `gitleaks` | always; fail the build | | Secrets | Rails credentials | leaking = breach | | Config | `config` / `anyway_config` (yml) | non-secret env shape | | Runtime boot | ENV | per-host/per-boot | | API errors | RFC 9457 `problem+json` | any external API | | Idempotency | `Idempotency-Key` + unique index | retryable writes | | Circuit breaker | `stoplight` / `semian` | flaky dependencies | | Auditing | `paper_trail` / `audited` / `logidze` | regulated/disputed data | | Flexible attrs | `jsonb` + `store_accessor` | sparse/evolving metadata | | Feature flags | `flipper` | decouple deploy/release | | Enums | Rails `enum` / `enumerize` | fixed value set, no transition rules | | State machine | `aasm` / `statesman` | guarded lifecycles (owns the enum column) | | Result types | `dry-monads` + `dry-contracts` | fail-able multi-step ops | | Events | `ActiveSupport::Notifications` / `rails_event_store` | fan-out side effects | | Health | `okcomputer` (deep `/health`) | every deployment | | Versioning | `svu` + Kamal pre/post-deploy hooks → git tag | traceable releases (see [[kamal-auto-version\|Kamal Auto-Versioning]]) | | N+1 / perf | `prosopite`, `test-prof` | list/index endpoints | | Testing | `rspec`, `factory_bot`, `vcr`, Playwright(MCP) | everywhere | | Observability | OTel, `yabeda`, `lograge`, Sentry | before you need it | | Correlation ID | `X-Correlation-ID` + `CurrentAttributes` | trace one op across processes | | Background/agents | `Sidekiq`/`Solid Queue`, bounded subagents | slow/parallel/AI work | | IDs | bigint internal · uuid external · `friendly_id` | public surfaces | | Scale | vertical first, then horizontal + replicas | at a measured bottleneck |