Multi-tenant SaaS with Rails for Ruby on Rails developers
Building a SaaS platform means serving multiple customers from a single application—and keeping their data completely isolated. Multi-tenant SaaS with Rails for Ruby on Rails developers is one of the most practical architectural decisions you'll make, because it lets you scale horizontally while keeping infrastructure costs reasonable. In this article, we'll walk through the strategies and patterns that work in production, from simple tenant columns to sophisticated scoping mechanisms.
Why multi-tenancy matters for SaaS
When you're building a SaaS product, you have two paths: single-tenant (one database per customer) or multi-tenant (many customers in one database). Multi-tenant saves on database maintenance, simplifies deployments, and scales more cost-effectively. The tradeoff is that you must be disciplent about data isolation—a query mistake could leak customer data.
Rails gives you excellent primitives for this. We'll cover the practical approaches we've used across e-commerce platforms, CRM systems, and trading applications serving hundreds of customers from one codebase.
Tenant column: account_id on every table
The foundation of multi-tenancy is dead simple: add an account_id column to every table that holds customer-specific data. This column acts as your tenant identifier.
class CreateAccounts < ActiveRecord::Migration[7.0]
def change
create_table :accounts do |t|
t.string :name, null: false
t.string :slug, null: false
t.timestamps
end
add_index :accounts, :slug, unique: true
end
end
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.bigint :account_id, null: false
t.string :email, null: false
t.string :password_digest
t.timestamps
end
add_index :users, [:account_id, :email], unique: true
add_foreign_key :users, :accounts
end
end
class CreateInvoices < ActiveRecord::Migration[7.0]
def change
create_table :invoices do |t|
t.bigint :account_id, null: false
t.bigint :user_id, null: false
t.decimal :amount, precision: 10, scale: 2
t.timestamps
end
add_index :invoices, [:account_id, :id]
add_foreign_key :invoices, :accounts
add_foreign_key :invoices, :users
end
endNotice the composite indexes. Query performance depends on scanning by account_id first. Always index account_id in combination with other columns you filter on. This is non-negotiable in production.
In your models, establish the relationships:
class Account < ApplicationRecord
has_many :users, dependent: :destroy
has_many :invoices, dependent: :destroy
end
class User < ApplicationRecord
belongs_to :account
has_many :invoices, dependent: :destroy
end
class Invoice < ApplicationRecord
belongs_to :account
belongs_to :user
endScope queries: Current.account in default scope or explicit scopes
Adding an account_id column is only step one. The real discipline is enforcing it on every query. Rails gives us scopes to do this elegantly.
A common pattern is to maintain a thread-local reference to the current account:
class Current < ActiveSupport::CurrentAttributes
attribute :account, :user
endSet this in your ApplicationController before every request:
class ApplicationController < ActionController::Base
before_action :set_current_account
private
def set_current_account
if user_signed_in?
Current.account = current_user.account
end
end
endNow, add a default scope to scoped models:
class Invoice < ApplicationRecord
belongs_to :account
belongs_to :user
default_scope { where(account: Current.account) if Current.account }
end
class User < ApplicationRecord
belongs_to :account
default_scope { where(account: Current.account) if Current.account }
endWith this in place, any query automatically filters by the current account:
Invoice.all
# Generates: SELECT * FROM invoices WHERE account_id = ?Be cautious with default scopes—they can surprise you when chaining scopes. Many teams prefer explicit scopes instead:
class Invoice < ApplicationRecord
belongs_to :account
scope :current_account, -> { where(account: Current.account) if Current.account }
scope :for_account, ->(account) { where(account: account) }
end
# Usage
Invoice.current_account.where(status: 'pending')The explicit approach forces you to think about when to apply the filter. We've found it catches bugs earlier in code review.
acts_as_tenant: A gem that enforces scoping
The acts_as_tenant gem (gem 'acts-as-tenant') automates tenant scoping and adds guardrails. It's particularly useful on larger teams where discipline can waver.
class Account < ApplicationRecord
has_many :invoices
end
class Invoice < ApplicationRecord
acts_as_tenant :account
belongs_to :account
belongs_to :user
endSet the current tenant in your controller:
class ApplicationController < ActionController::Base
before_action :set_tenant
private
def set_tenant
if user_signed_in?
ActsAsTenant.current_tenant = current_user.account
end
end
endNow queries are automatically scoped:
Invoice.all
# Generates: SELECT * FROM invoices WHERE account_id = ?
# Attempting to unscope raises an error:
Invoice.unscoped.find(id)
# ActsAsTenant::Errors::TenantIsImmutableThe gem also validates associations to prevent cross-tenant data leaks:
invoice = Invoice.create!(
account_id: Account.first.id,
user_id: User.where(account: Account.second.id).first.id # Raises error
)
# ActsAsTenant::Errors::TenantIsImmutableThis is powerful for teams working on complex applications where a junior developer might accidentally allow cross-tenant queries. The trade-off is you lose some flexibility when you legitimately need to unscope (e.g., background jobs processing all tenants). Keep that in mind before adopting it.
Subdomains: Resolve tenant from the request
In production, you'll resolve the current tenant from the HTTP request. Subdomains are a clean pattern: acme.yourapp.com routes to the Acme account, while widgets.yourapp.com routes to Widgets.
Add a helper to extract the subdomain:
module TenantHelper
def current_account_from_subdomain
return nil if request.subdomain.blank? || request.subdomain == 'www'
Account.find_by(slug: request.subdomain)
end
endIntegrate it into your authentication flow:
class ApplicationController < ActionController::Base
before_action :authenticate_and_set_tenant
private
def authenticate_and_set_tenant
account = current_account_from_subdomain
user = authenticate_user!
unless user.account == account
raise "User does not belong to this account"
end
Current.account = account
end
def current_account_from_subdomain
return nil if request.subdomain.blank? || request.subdomain == 'www'
Account.find_by!(slug: request.subdomain)
end
endConfigure your Rails routes to enforce subdomains in development and production:
Rails.application.routes.draw do
constraints subdomain: /^(?!www$)/ do
resources :invoices
resources :users
end
constraints subdomain: 'www' do
root to: 'marketing#index'
resources :accounts, only: [:new, :create]
end
endIn your DNS and load balancer, configure a wildcard: *.yourapp.com points to your app. SSL certificates must also cover the wildcard domain (most registrars support this cheaply now).
For development, add this to your config/hosts:
127.0.0.1 localhost yourapp.local acme.yourapp.local widgets.yourapp.localCommon mistakes
Forgetting to scope in background jobs. Sidekiq workers don't inherit the request context, so Current.account is nil. Always pass the account_id explicitly:
class InvoiceGeneratorJob
include Sidekiq::Job
def perform(invoice_id, account_id)
Current.account = Account.find(account_id)
invoice = Invoice.find(invoice_id)
# Process invoice...
end
end
# Enqueue:
InvoiceGeneratorJob.perform_async(invoice.id, invoice.account_id)Using join queries without tenant filters. When joining tables, both sides must filter by account_id, or you risk leaking data:
# WRONG:
Invoice.joins(:user).where(users: { email: 'user@example.com' })
# RIGHT:
Invoice.joins(:user)
.where(invoices: { account_id: Current.account })
.where(users: { account_id: Current.account })
.where(users: { email: 'user@example.com' })Ignoring database constraints. Add NOT NULL constraints and foreign keys to enforce integrity at the database level. This catches application bugs early and prevents data corruption.
Lazy tenant resolution on API endpoints. If you're building a public API, resolve tenants from an API key, not a subdomain. Otherwise, a user could impersonate another tenant by changing the subdomain.
Key takeaways
- Add an
account_idcolumn to every customer-scoped table and index it first in composite keys. - Use
Current.accountto maintain thread-local tenant context and apply explicit scopes to queries. - Consider
acts_as_tenantfor teams that need built-in guardrails against accidental cross-tenant queries. - Resolve tenants from subdomains, API keys, or URL paths consistently across all request types (HTTP, WebSocket, background jobs).
- Test cross-tenant isolation with integration tests that verify Account A cannot access Account B's data.
- Use database constraints (NOT NULL, foreign keys) to prevent data corruption at the persistence layer.






Comments
Loading comments…