Rails 8 Multi-Tenancy with MySQL

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
end

The 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
end

Every 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
end

With 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
end

The 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
end

The 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
end

Common 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_id in 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.

10 claps
← Back to Blog