Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 072 / 100

Building a SaaS with Rails 8 for Ruby on Rails developers

A modern Rails stack end to end.

Expona Admin6 min read

Rails 8 ships with features that make building a production SaaS straightforward without reaching for dozens of third-party services. We've moved beyond the days of bolting together authentication gems, background job systems, and deployment tools—Rails now provides integrated solutions that work out of the box. Building a SaaS with Rails 8 for Ruby on Rails developers means leveraging these built-in capabilities alongside proven libraries like Pay for billing, reducing your infrastructure decisions and letting you focus on business logic.

Auth — Built-in Authentication Generator

Rails 8 introduced a native authentication generator that replaces the friction of choosing between Devise, OmniAuth, and custom solutions. When you run rails generate authentication, you get a minimal, audited authentication system without external gem dependencies.

Let's say you're launching a project management SaaS. You run the generator and get a User model with password hashing via bcrypt, session management, and password reset flow out of the box:

ruby
bin/rails generate authentication

This creates:

  • A User model with has_secure_password
  • Sessions controller for login/logout
  • PasswordsController for reset flows
  • Email verification if needed
  • HTTP Basic Auth helpers for API endpoints

For a real scenario: you want to protect your API endpoints. The generated code gives you authenticate_with_http_basic for free. To guard a billing endpoint:

ruby
class Api::BillingController < ApplicationController
  before_action :authenticate

  def current_plan
    render json: { plan: current_user.subscription.plan }
  end

  private

  def authenticate
    authenticate_with_http_basic { |email, password| User.authenticate(email, password) }
  end
end

The security model here is solid: passwords are hashed with bcrypt, there's built-in protection against timing attacks, and the session cookie is HTTP-only by default. You avoid the maintenance burden of keeping a gem like Devise in sync across major Rails versions.

Billing — Pay Gem with Stripe

Once users are authenticated, you need to handle billing. The Pay gem (maintained by the Rails team at 37signals) wraps Stripe, Braintree, and Paddle with a unified Ruby interface. It handles subscriptions, one-time charges, webhooks, and invoice management.

Install and configure Pay in your Rails 8 app:

ruby
bundle add pay

bin/rails pay:install:migrations
bin/rails db:migrate

Add the relation to your User model:

ruby
class User < ApplicationRecord
  has_one :pay_customer, class_name: "Pay::Customer"
end

In a real SaaS scenario—say, a content management platform with tiered pricing—you'd create a subscription like this:

ruby
class BillingController < ApplicationController
  def create_subscription
    current_user.pay_customer.subscribe(
      name: "Pro Monthly",
      plan: "pro-monthly",
      trial_period_days: 14
    )
    redirect_to dashboard_path, notice: "Subscription started"
  end

  def cancel_subscription
    current_user.pay_customer.subscription.cancel
    redirect_to dashboard_path, notice: "Subscription cancelled"
  end
end

Pay handles webhook processing automatically. When Stripe posts a charge.succeeded or customer.subscription.deleted event, Pay routes it to your database and triggers ActiveRecord callbacks. This means you can hook into subscription changes without manually writing webhook handlers:

ruby
class Pay::Subscription
  after_update_commit :log_change_to_analytics

  private

  def log_change_to_analytics
    Analytics.track(user_id, "subscription_updated", { plan: plan })
  end
end

The Pay gem keeps your billing logic DRY and reduces the surface area for security mistakes. Stripe tokens never touch your server; payment data is PCI-compliant by design.

Jobs and Cache — Solid Queue and Solid Cache

Rails 8 ships with Solid Queue (a job queue built on SQLite/PostgreSQL) and Solid Cache (an in-process cache using the same databases). These replace Redis for many workloads, simplifying your infrastructure.

Solid Queue is ideal for sending emails, processing uploads, or syncing data without blocking web requests. In a real scenario—a SaaS that notifies users of subscription renewals—you'd queue an email job:

ruby
class SubscriptionRenewalJob < ApplicationJob
  queue_as :critical

  def perform(subscription_id)
    subscription = Pay::Subscription.find(subscription_id)
    user = subscription.customer.owner
    
    SubscriptionMailer.renewal_reminder(user).deliver_later
    Analytics.track(user.id, "renewal_email_sent")
  end
end

Then trigger it from your webhook handler:

ruby
class WebhooksController < ApplicationController
  skip_forgery_protection

  def stripe
    event = Stripe::Webhook.construct_event(
      request.body.read,
      request.headers["Stripe-Signature"],
      ENV["STRIPE_WEBHOOK_SECRET"]
    )

    case event.type
    when "customer.subscription.created"
      subscription = Pay::Subscription.sync(event.data.object.id)
      SubscriptionRenewalJob.set(wait: 6.months).perform_later(subscription.id)
    end

    head :ok
  end
end

For caching, Solid Cache provides an ActiveSupport cache store that persists to your database. No separate Redis cluster to manage. Configure it in config/environments/production.rb:

ruby
config.cache_store = :solid_cache_store

Cache frequently accessed data—like the user's current plan or feature flags:

ruby
def current_plan
  Rails.cache.fetch("user:#{current_user.id}:plan", expires_in: 1.hour) do
    current_user.pay_customer.subscription&.plan
  end
end

Both Solid Queue and Solid Cache are production-ready for SaaS at small to medium scale. They eliminate the operational overhead of running Redis while keeping your code simple.

Deploy — Kamal on a VPS or Cloud Server

Kamal (built by 37signals) deploys Rails apps as Docker containers with zero downtime. It works with any cloud provider—AWS EC2, DigitalOcean, Hetzner, or bare metal. You define your deployment in one config file and push with a single command.

Create a config/deploy.yml at the root of your Rails project:

ruby
service: my-saas
image: my-registry/my-saas

servers:
  web:
    hosts:
      - 203.0.113.10
    labels:
      traefik: true

registry:
  server: registry.digitalocean.com
  username: <%= ENV["REGISTRY_USER"] %>
  password: <%= ENV["REGISTRY_PASSWORD"] %>

env:
  clear:
    RAILS_ENV: production
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_URL
    - STRIPE_SECRET_KEY

traefik:
  image: traefik:v2.10
  options:
    publish:
      - "80:80"
      - "443:443"
  args:
    certificatesresolvers.letsencrypt.acme.email: ops@my-saas.com
    certificatesresolvers.letsencrypt.acme.storage: /letsencrypt/acme.json
    certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint: web

On your development machine, initialize the deployment:

ruby
kamal setup

This creates the necessary directories on your VPS, sets up Docker, starts Traefik for SSL termination, and pulls your app image. From then on, deploy with:

ruby
kamal deploy

Kamal runs database migrations automatically, restarts your web containers with zero downtime (via a rolling restart strategy), and rolls back if something fails. For a SaaS that can't afford downtime, this is critical.

Real-world example: you've released a feature that queries the database slowly. You notice latency creeping up. You fix the N+1 query, push to git, and run kamal deploy. Kamal spins up a new container with your fix, routes traffic to it, and tears down the old one—all while serving requests uninterrupted.

Kamal also handles environment secrets securely, keeps your deployment reproducible across team members, and integrates GitHub Actions or similar CI/CD tools. The entire deployment model is declarative and version-controlled.

Common Mistakes

  • Assuming Pay handles all PCI compliance automatically. It does most of the heavy lifting, but you still need to follow best practices: never log sensitive card data, validate input, and periodically audit webhook handling.
  • Using Solid Queue without monitoring job backlog. In production, monitor your queue depth. If jobs pile up, you may need horizontal scaling or to tune worker concurrency.
  • Deploying with Kamal without testing on a staging server first. Use a separate Kamal deployment for staging. Test your entire flow—auth, billing, jobs, emails—before deploying to production.
  • Ignoring Stripe webhook failures. Pay logs webhooks, but you should still monitor webhook delivery from your Stripe dashboard. A failed webhook can leave your database and Stripe out of sync.
  • Caching user data too aggressively. If you cache a user's subscription status for 1 hour, they may not see a plan upgrade immediately. Use shorter TTLs for data that changes frequently, or invalidate the cache explicitly.

Key Takeaways

  • Rails 8's built-in authentication generator provides a secure, maintainable alternative to Devise for new projects, reducing external dependencies.
  • The Pay gem unifies billing across Stripe, Braintree, and Paddle, handling subscriptions, webhooks, and invoices without custom code.
  • Solid Queue and Solid Cache simplify your infrastructure by eliminating Redis for most small- to medium-scale SaaS applications.
  • Kamal delivers zero-downtime deployments with declarative configuration, making it straightforward to run a production SaaS on any cloud provider or VPS.
  • Together, these tools form a cohesive, production-ready stack that lets you launch and iterate on your SaaS business logic instead of fighting infrastructure.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles