Rails Stack 2026: Rails 8.1 for Production Systems
The Rails 8.1 stack I use for serious operational software: PostgreSQL, Phlex, Hotwire, Solid Queue and Active Storage, tested against real production data.
A Rails 8.1 application I work on holds nearly 7 million observation records, more than 17 million attachments and more than 4 TB of media, while its 100 GB database supports a UK property inventory agency's operations workspace and client portal, imports and preserves third-party inspection history, serves private reports and invoices, and keeps integrations from rewriting each other across more than 25,000 properties and 60,000 inspections, some dating to 2011.
That makes my preferences practical, so I start with Ruby 3.3, Rails 8.1, PostgreSQL, Hotwire, Phlex, Tailwind, Solid Queue, Solid Cache, Active Storage, AppSignal, Sentry and Rack::Attack, changing parts only for product reasons.
I start with the database that can carry the awkward data
PostgreSQL is the default for shared, operational or long-lived work, while SQLite suits a small single-user product with a clear boundary, although webhook deliveries, audit records, import cursors, role-specific workflows and several integrations want PostgreSQL before clever architecture.
The property platform has relational tables for clients, properties, inspections, reports, invoices and payments, plus source snapshots, integration events, reconciliation records, audit events and Solid Queue metadata, with JSONB-backed snapshots because the input is not a clean domain model on day one.
An inspection import starts with a paginated list, then fetches property or inspection detail, contacts, report content and metadata, meters and attachments, and stores every response before canonical projection so six months later I can tell whether the provider sent a value or we made it.
I rejected a document store as primary because this is not a document archive: reports belong to inspections, portal access is scoped to clients, invoices have statuses and private PDFs, replayed webhooks must not create second events, and PostgreSQL supplies foreign keys, indexes, transactions and constraints alongside JSONB for shifting input.
The education platform has course deliveries, ordered steps, volunteer matches, feedback responses and judging records, where a volunteer must not match a request twice and a judge must not rate an entry twice, so unique indexes decide when two requests arrive together.
JSONB is a quarantine area, not permission to avoid modelling, so I use it for provider payloads, integration metadata and snapshots, then project relied-on fields into named, constrained columns, because a dashboard query unpacking five JSON layers means discovery lasted too long.
A 100 GB production database makes bad indexes, casual includes and schema changes visible, while large media still does not belong in it, and Rails is comfortable at this scale although capacity planning remains my job.
I keep the application in one Rails monolith until it proves it needs more
The platform talks to an inventory provider, Airtable and Xero, sends one-time passcodes through Postmark, and has admin and client workspaces, signed webhooks, background imports, private documents and a dead-letter and reconciliation console, which is why I prefer one Rails application.
Splitting early into an import API, frontend, webhook worker and portal makes identifiers, permissions and audit events distributed problems, each needing deployment, logs, credentials, database ownership and a failure story; the old system had a PHP webhook service, MySQL queue state, cron processing and manual rerun screens, while Rails replaced it with signed endpoints, integration ledgers, queue-backed work, replay controls and local canonical records, where operators can see complexity.
A monolith is not every line in a controller: import selection, provider clients, canonical upserts, webhook verification and media adoption are tested services, but their transactions and data model are adjacent, so when a root import succeeds before its cursor advances, I can inspect its cursor, snapshot and projection without a network boundary.
The Phoenix judging application is a counterpoint, because OTP processes fit timer-driven polling and scheduled emails, but timers reset after restarts and multi-instance deployments need coordination because every supervision tree starts them; Rails is not better merely for being Rails, and durable work needs deliberate persistence.
One deployed application with PostgreSQL is the true boundary here, so I can extract a part that proves different scaling or availability needs rather than pay a distributed-systems tax for a future that has not arrived.
Phlex is the view layer I can keep reading
I use Phlex rather than ERB by default because of failure modes, not fashion: ERB is excellent for a small page, but then it gains a filter form, table row and status badge, empty state, modal, two permission branches and a compact Turbo-frame version, where punctuation separates HTML and Ruby, partial locals become an informal API, and component shape spreads across template, helper and CSS strings, which older Rails applications show does not stay neat by accident.
Phlex makes a component an object with an explicit initializer and Ruby method that writes HTML, so a missing keyword fails at the call rather than as a half-correct partial, while conditions and loops are Ruby and an admin surface answers why a user sees a button.
Phlex can become noisy when every paragraph, icon and wrapper is a component, so I reserve components for a visual contract, permission rule, repeated interaction or domain concept: inspection state, portal document access and destructive admin actions qualify, while a one-off sentence does not.
ViewComponent was the serious alternative, because it fixes partial sprawl and suits an existing application, but for new work Phlex has the smaller mental model: Ruby is the template language, its API is regular Ruby, and I can test rendering without another template convention.
That is why I do not default to React: an SPA adds an API boundary, client-cache state, duplicated validation and JavaScript for forms, lists, workflows and documents, while the property platform needs inspection preparation, field correction and private PDFs, not a second application pretending the server is an API company.
Turbo and Stimulus cover the interaction without a second frontend
Hotwire uses less JavaScript where less is honest: Turbo keeps the server in control, so a portal user can request an asynchronous ZIP bundle of reports while an administrator works through imported records and reconciliation states without full-page resets, with Frames updating focused regions as links and forms remain HTML and Streams updating status after background work.
Stimulus handles browser-local behaviour - chosen-file display, dialogs, filter interactions and lightweight state beside its elements - and I prefer it to a large client-state store because operational state comes from the server, where a record is prepared, blocked or complete and permissions allow an action or do not.
I reject React, Vue and their ecosystems as defaults because they put ordinary business software's hard part in a second runtime, where browser and server validation can disagree and permissions can briefly render the wrong control; Turbo forms still need error handling, but one source of truth governs the workflow.
Dense drag interactions, offline-first field work, a map with thousands of live points or a collaborative editor can justify a richer client, but a dropdown updating a table cannot.
Tailwind lets me adjust a server-rendered interface without a large naming system before the product settles, although type, spacing, colour and layout still belong in shared tokens and components, because without them Tailwind is an unreadable paragraph on every element.
Solid Queue is the right default when the job state belongs with the product
Sidekiq is the wrong default for this stack, not a bad system: the older public platform uses Sidekiq with Redis for CMS webhook cache invalidation, sitemap updates and scheduled reminders, which is reasonable, so I would not migrate it to make a dependency graph newer.
For new Rails 8.1 work I choose Solid Queue because PostgreSQL is already the durable record, removing otherwise-unneeded Redis and keeping job tables, queue controls and failure state where I can back them up, inspect them and relate them to triggering records.
On the property platform, imports page through external records, fetch dependencies, preserve snapshots, project records and download large media, while webhooks must be acknowledged quickly and media cannot clog the integration queue, so the system separates work, exposes dead letters and replays, and persists a signed integration event before handing off work instead of migrating in a controller.
Solid Queue does not repair bad design, so I still need idempotency, a real cursor and explicit retries: the import retries 429 and transient 5xx responses up to four times, honours Retry-After when present and otherwise uses bounded exponential backoff, while a root cursor advances only after success, synchronised roots are skipped, and webhooks never overwrite newer values.
I would choose Sidekiq when Redis has another strong use, job throughput is the established dominant constraint, or a mature Sidekiq operation exists, because familiarity alone does not justify a data store, backup concern and production service.
Solid Cache follows the same rule: database-backed cache is not free or right for every hot path, but it avoids Redis solely for caching, while the older content platform uses Redis for a CMS page index and Sidekiq workers, which is coherent, and a fresh application starts with fewer parts and adds one after measurement.
Active Storage needs a media policy, not blind trust
Active Storage gives Rails attachments, signed delivery, direct uploads and variants, while the portal serves private report PDFs and client documents through authenticated application endpoints rather than provider URLs.
It is not a migration plan: the property platform uses current read-write DigitalOcean Spaces and read-only legacy storage, some images are adopted while others need direct download, filename matching is allowed only when safe, and heavyweight queues keep a 2014 image from blocking a current webhook.
Verification found an earlier download path omitted cover images and meter photographs without UUIDs, while a later check confirmed all 17,595 distinct cover-image URLs and 42,494 distinct meter-photo URLs in the bounded migration set were in imported storage, so the source stayed until remaining actions backfill completed.
I do not make a generic cloud SDK the file model, because it supplies storage primitives rather than attachment ownership, access control, Rails integration or proof that a historic archive arrived intact; Active Storage handles the first, while snapshots, manifests, worklists and read-only verification handle the second.
More than 4 TB of media makes storage cost, lifecycle policy, egress, retries and worker isolation design inputs, because a media archive is an operations project.
Observability and rate controls belong in the first deploy
I use AppSignal for performance and operations, Sentry for errors and Rack::Attack for rate controls, which tell me when a feature failed.
The public education platform has Rack::Attack, a honeypot and Turnstile around public forms, while the property platform has signed webhooks, one-time-passcode login and private document endpoints, and both must refuse bad traffic before it becomes support work.
Integration failures are ambiguous - temporary 5xx, rate limit, retry or duplicate webhook - so the platform records retry audit events, integration events and reconciliation state because a graph cannot answer an operator at 9am.
I do not call a migration complete because a job counter reaches zero: a per-property manifest separated report content from image-backup status and found a material media gap, where the database had rows but the archive was incomplete, and the stack must reveal that even when it worsens the delivery date.
This stack keeps relational data, jobs, cache, files, rendered HTML and repair evidence close, while a much larger real-time client, offline-first product or workload proving PostgreSQL job throughput is the bottleneck will earn a change.
For the deployment and recovery side of this approach, I wrote about hosting as a trust problem; for the test boundary around imported data and generated changes, read tests as contracts.
I build Rails products and take responsibility for the awkward production parts, and my CV and rates are here.