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:
bin/rails generate authenticationThis 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:
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
endThe 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:
bundle add pay
bin/rails pay:install:migrations
bin/rails db:migrateAdd the relation to your User model:
class User < ApplicationRecord
has_one :pay_customer, class_name: "Pay::Customer"
endIn a real SaaS scenario—say, a content management platform with tiered pricing—you'd create a subscription like this:
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
endPay 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:
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
endThe 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:
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
endThen trigger it from your webhook handler:
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
endFor 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:
config.cache_store = :solid_cache_storeCache frequently accessed data—like the user's current plan or feature flags:
def current_plan
Rails.cache.fetch("user:#{current_user.id}:plan", expires_in: 1.hour) do
current_user.pay_customer.subscription&.plan
end
endBoth 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:
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: webOn your development machine, initialize the deployment:
kamal setupThis 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:
kamal deployKamal 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.






Comments
Loading comments…