Multi-tenancy allows a single Rails application to serve multiple customers (tenants) while keeping their data isolated. This pattern powers SaaS applications where each organization needs their own private space within shared infrastructure.
Rails 8 provides the tools to implement multi-tenancy cleanly. This guide covers the two most practical approaches: row-level scoping with a tenant_id column, and MySQL's database-level isolation.
Choosing a Multi-Tenancy Strategy
Three strategies exist for multi-tenancy, each with trade-offs:
- Row-level scoping — All tenants share tables, distinguished by a tenant_id column. Simplest to implement, easiest to maintain.
- Schema/database per tenant — Each tenant gets isolated tables. Stronger isolation, more complex migrations.
- Separate databases — Complete isolation. Best security, highest operational overhead.
For most Rails applications, row-level scoping offers the best balance. The implementation below uses this approach with MySQL.
Setting Up the Tenant Model
Start by creating a Tenant model that represents each customer organization:
# db/migrate/20260105000001_create_tenants.rb
class CreateTenants < ActiveRecord::Migration[8.0]
def change
create_table :tenants do |t|
t.string :name, null: false
t.string :subdomain, null: false, index: { unique: true }
t.string :domain
t.timestamps
end
end
end# app/models/tenant.rb
class Tenant < ApplicationRecord
validates :name, presence: true
validates :subdomain, presence: true, uniqueness: true,
format: { with: /\A[a-z][a-z0-9-]*\z/ }
has_many :users, dependent: :destroy
has_many :projects, dependent: :destroy
endThe subdomain field enables tenant identification from URLs like acme.myapp.com. The domain field supports custom domains for enterprise customers.
Implementing Current Tenant Context
Rails 8 applications need a thread-safe way to track the current tenant throughout each request. The Current class pattern handles this elegantly:
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :tenant, :user
def tenant=(tenant)
super
# Automatically scope all tenant-aware queries
tenant ? ActsAsTenant.current_tenant = tenant : ActsAsTenant.current_tenant = nil
end
end# app/controllers/concerns/set_current_tenant.rb
module SetCurrentTenant
extend ActiveSupport::Concern
included do
before_action :set_current_tenant
end
private
def set_current_tenant
Current.tenant = resolve_tenant
raise TenantNotFound unless Current.tenant
end
def resolve_tenant
# Priority: custom domain, then subdomain
Tenant.find_by(domain: request.host) ||
Tenant.find_by(subdomain: extract_subdomain)
end
def extract_subdomain
request.subdomain.presence
end
end
class TenantNotFound < StandardError; end# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include SetCurrentTenant
rescue_from TenantNotFound do
render file: Rails.public_path.join("404.html"),
status: :not_found, layout: false
end
endEvery controller now automatically resolves and sets the current tenant before processing requests.
Creating Tenant-Scoped Models
Models belonging to a tenant need automatic scoping. A concern handles this without external dependencies:
# app/models/concerns/belongs_to_tenant.rb
module BelongsToTenant
extend ActiveSupport::Concern
included do
belongs_to :tenant
validates :tenant, presence: true
default_scope -> { where(tenant: Current.tenant) if Current.tenant }
before_validation :set_tenant, on: :create
end
private
def set_tenant
self.tenant ||= Current.tenant
end
end# app/models/project.rb
class Project < ApplicationRecord
include BelongsToTenant
has_many :tasks, dependent: :destroy
validates :name, presence: true
end# db/migrate/20260105000002_create_projects.rb
class CreateProjects < ActiveRecord::Migration[8.0]
def change
create_table :projects do |t|
t.references :tenant, null: false, foreign_key: true
t.string :name, null: false
t.text :description
t.timestamps
t.index [:tenant_id, :name]
end
end
endWith this setup, Project.all automatically returns only projects for the current tenant. New projects automatically receive the current tenant assignment.
MySQL Indexing for Multi-Tenant Queries
Every query in a multi-tenant app filters by tenant_id. Composite indexes that lead with tenant_id dramatically improve performance:
# db/migrate/20260105000003_add_tenant_indexes.rb
class AddTenantIndexes < ActiveRecord::Migration[8.0]
def change
# Composite indexes for common queries
add_index :projects, [:tenant_id, :created_at]
add_index :tasks, [:tenant_id, :project_id, :status]
add_index :users, [:tenant_id, :email], unique: true
end
endThe index on [:tenant_id, :email] enforces that email addresses are unique within each tenant, not globally. This allows the same person to have accounts in multiple organizations.
Handling Cross-Tenant Operations
Administrative tasks sometimes require bypassing tenant scoping. Use unscoped blocks carefully:
# app/services/admin/tenant_statistics.rb
module Admin
class TenantStatistics
def self.generate_all
# Temporarily bypass tenant scoping
Tenant.find_each do |tenant|
Current.tenant = tenant
stats = {
projects_count: Project.count,
active_users: User.where(active: true).count,
storage_used: calculate_storage(tenant)
}
TenantMetric.create!(tenant: tenant, data: stats)
end
ensure
Current.tenant = nil
end
def self.calculate_storage(tenant)
# Implementation details
end
end
endThe ensure block guarantees tenant context resets even if an exception occurs, preventing data leakage between tenants.
Testing Multi-Tenant Code
Tests must explicitly set tenant context. A helper simplifies this:
# spec/support/tenant_helpers.rb
module TenantHelpers
def with_tenant(tenant, &block)
Current.tenant = tenant
yield
ensure
Current.tenant = nil
end
def current_tenant
Current.tenant
end
end
RSpec.configure do |config|
config.include TenantHelpers
config.before(:each) do
Current.reset
end
end# spec/models/project_spec.rb
RSpec.describe Project do
let(:tenant_a) { create(:tenant, subdomain: "acme") }
let(:tenant_b) { create(:tenant, subdomain: "globex") }
describe "tenant scoping" do
it "isolates projects by tenant" do
with_tenant(tenant_a) do
create(:project, name: "Alpha")
end
with_tenant(tenant_b) do
create(:project, name: "Beta")
expect(Project.pluck(:name)).to eq(["Beta"])
end
end
end
endCommon Pitfalls to Avoid
Several mistakes commonly appear in multi-tenant implementations:
- Background jobs losing context — Always serialize tenant_id in job arguments and restore context before processing.
- N+1 queries on tenant — Include
.includes(:tenant)when tenant data is needed in views. - Global uniqueness validations — Use
scope: :tenant_idin uniqueness validations. - Forgetting to scope joins — Joins can leak data across tenants if not properly constrained.
Summary
Multi-tenancy in Rails 8 requires three core components: a Tenant model, a Current context system, and a concern for scoping tenant-owned records. MySQL composite indexes with tenant_id as the leading column ensure queries remain fast as data grows. The implementation above provides complete tenant isolation without external gems, keeping the dependency footprint minimal while maintaining full control over the scoping logic.