Multi-Tenancy in Rails 8 with acts_as_tenant

A practical guide to implementing apartment-style multi-tenancy in modern Rails applications


What is Multi-Tenancy?

Multi-tenancy lets you serve multiple customers (tenants) from a single application instance, with data isolation between them.

Real-world example: PropertyWebBuilder lets multiple real estate agencies run their websites from one Rails app. Each agency (tenant) only sees their properties, users, and settings.

Three approaches:
1. Separate databases — Each tenant gets their own DB (highest isolation, complex)
2. Shared database, separate schemas — PostgreSQL schemas (good isolation, moderate complexity)
3. Shared database, shared schema — Tenant ID column on every table (simplest, most common)

This guide covers approach #3 using the acts_as_tenant gem in Rails 8.


Why acts_as_tenant?

Alternatives:
- Apartment gem (schema-based, more complex)
- Milia gem (devise-focused)
- Roll your own (reinventing wheels)

acts_as_tenant wins because:
- ✅ Simple: One tenant_id column
- ✅ Automatic scoping (no manual where(tenant_id: ...) everywhere)
- ✅ Rails 8 compatible
- ✅ Thread-safe (important for concurrent requests)
- ✅ Flexible (works with any model as tenant)

GitHub: https://github.com/ErwinM/acts_as_tenant


Getting Started

Installation

# Gemfile
gem 'acts_as_tenant'

bundle install

Define Your Tenant Model

For PropertyWebBuilder, the tenant is an Account:

# app/models/account.rb
class Account < ApplicationRecord
  has_many :users, dependent: :destroy
  has_many :properties, dependent: :destroy
  has_many :inquiries, dependent: :destroy

  validates :name, presence: true
  validates :subdomain, presence: true, uniqueness: true

  # Optional: Customize how tenant is identified
  def to_param
    subdomain
  end
end

Migration:

# db/migrate/20250104000001_create_accounts.rb
class CreateAccounts < ActiveRecord::Migration[8.0]
  def change
    create_table :accounts do |t|
      t.string :name, null: false
      t.string :subdomain, null: false, index: { unique: true }
      t.string :domain # Optional: custom domains
      t.jsonb :settings, default: {} # Tenant-specific configuration

      t.timestamps
    end
  end
end

Add Tenant ID to Models

Every model that should be scoped to a tenant needs a tenant_id (or account_id):

# db/migrate/20250104000002_add_account_id_to_properties.rb
class AddAccountIdToProperties < ActiveRecord::Migration[8.0]
  def change
    add_reference :pwb_properties, :account, null: false, foreign_key: true, index: true
    add_reference :pwb_users, :account, null: false, foreign_key: true, index: true
    add_reference :pwb_inquiries, :account, null: false, foreign_key: true, index: true
  end
end

Important: Add indexes on account_id — every query will filter by this column.


Model Setup

Declare Tenant Scoping

# app/models/property.rb
class Property < ApplicationRecord
  acts_as_tenant :account

  # Regular associations and validations
  belongs_to :user
  has_many_attached :images

  validates :title, presence: true
  validates :price, numericality: { greater_than: 0 }
end

What acts_as_tenant :account does:
- Automatically adds default_scope { where(account_id: ActsAsTenant.current_tenant.id) }
- Validates presence of account_id
- Sets account_id on create if current_tenant is set

Models Without Scoping

Some models are global (not tenant-specific):

# app/models/admin_user.rb
class AdminUser < ApplicationRecord
  # No acts_as_tenant — this model is global
  validates :email, presence: true, uniqueness: true
end

Controller Setup

Setting Current Tenant

Option 1: Subdomain-based

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  set_current_tenant_through_filter
  before_action :set_tenant

  private

  def set_tenant
    account = Account.find_by!(subdomain: request.subdomain)
    set_current_tenant(account)
  rescue ActiveRecord::RecordNotFound
    redirect_to root_url(subdomain: false), alert: "Account not found"
  end
end

How it works:
- User visits agency123.yourapp.com
- request.subdomain = "agency123"
- Finds Account with matching subdomain
- Sets as current_tenant
- All queries automatically scoped to that account

Option 2: Custom Domain

def set_tenant
  # Check if custom domain
  account = Account.find_by(domain: request.host)

  # Fallback to subdomain
  account ||= Account.find_by!(subdomain: request.subdomain) if request.subdomain.present?

  set_current_tenant(account)
end

Option 3: Session-based (for single-domain SaaS)

def set_tenant
  account_id = session[:account_id] || current_user&.account_id
  account = Account.find(account_id)
  set_current_tenant(account)
end

Advanced Patterns

Creating Records

Automatic tenant assignment:

# Current tenant is set, so this works:
property = Property.create!(title: "Beach House", price: 500_000)
# property.account_id is automatically set

Explicit tenant assignment (for background jobs):

ActsAsTenant.with_tenant(account) do
  Property.create!(title: "Beach House", price: 500_000)
end

Querying Across Tenants (Admin Views)

Problem: Admins need to see all properties across all tenants.

Solution: Unscoped queries

# app/controllers/admin/properties_controller.rb
class Admin::PropertiesController < AdminController
  def index
    # See ALL properties (no tenant scoping)
    @properties = Property.unscoped.includes(:account).all
  end
end

Or specific tenant:

ActsAsTenant.without_tenant do
  properties = Property.where(account_id: [1, 2, 3])
end

Associations Across Tenants

Problem: What if properties belong to users in different tenants?

Bad:

# This will fail if user and property are in different tenants
property.user

Good:

# Explicitly validate within same tenant
class Property < ApplicationRecord
  acts_as_tenant :account
  belongs_to :user

  validate :user_belongs_to_account

  private

  def user_belongs_to_account
    return unless user_id
    unless User.where(account_id: account_id, id: user_id).exists?
      errors.add(:user, "must belong to the same account")
    end
  end
end

Background Jobs

Problem: Sidekiq/Solid Queue jobs don't have current_tenant context.

Solution: Pass tenant ID explicitly

# app/jobs/property_sync_job.rb
class PropertySyncJob < ApplicationJob
  queue_as :default

  def perform(property_id, account_id)
    account = Account.find(account_id)

    ActsAsTenant.with_tenant(account) do
      property = Property.find(property_id)
      # ... sync logic
    end
  end
end

Enqueue with context:

PropertySyncJob.perform_later(property.id, property.account_id)

Rails 8 Specifics

Solid Queue

Rails 8's default job backend works seamlessly with acts_as_tenant:

# config/application.rb
config.active_job.queue_adapter = :solid_queue

# Just pass account_id as shown above

Turbo Frames & Tenancy

Problem: Turbo requests need tenant context.

Solution: Already handled if using subdomain/domain scoping:

<!-- app/views/properties/index.html.erb -->
<%= turbo_frame_tag "properties" do %>
  <%= render @properties %>
<% end %>

<!-- Turbo requests include subdomain automatically -->

ActionCable (WebSockets)

Problem: WebSocket connections need tenant scoping.

Solution:

# app/channels/properties_channel.rb
class PropertiesChannel < ApplicationCable::Channel
  def subscribed
    account = Account.find_by(subdomain: params[:subdomain])
    ActsAsTenant.current_tenant = account

    stream_for account
  end

  def receive(data)
    # All queries are scoped to current_tenant
    property = Property.find(data['property_id'])
    # ...
  end
end

Testing

RSpec Setup

# spec/rails_helper.rb
RSpec.configure do |config|
  config.before(:each) do
    # Clear tenant context between tests
    ActsAsTenant.current_tenant = nil
  end
end

Example Specs

# spec/models/property_spec.rb
require 'rails_helper'

RSpec.describe Property, type: :model do
  let(:account1) { create(:account) }
  let(:account2) { create(:account) }

  before { ActsAsTenant.current_tenant = account1 }

  it "scopes queries to current tenant" do
    property1 = create(:property, account: account1)
    property2 = create(:property, account: account2)

    expect(Property.all).to include(property1)
    expect(Property.all).not_to include(property2)
  end

  it "sets account_id automatically" do
    property = Property.create!(title: "Test", price: 100_000)
    expect(property.account_id).to eq(account1.id)
  end

  it "prevents creating records without tenant" do
    ActsAsTenant.current_tenant = nil

    expect {
      Property.create!(title: "Test", price: 100_000)
    }.to raise_error(ActsAsTenant::Errors::NoTenantSet)
  end
end

Feature Specs with Subdomains

# spec/features/properties_spec.rb
require 'rails_helper'

RSpec.describe "Properties", type: :feature do
  let(:account) { create(:account, subdomain: 'testaccount') }

  before do
    Capybara.app_host = "http://testaccount.example.com"
    ActsAsTenant.current_tenant = account
  end

  after do
    Capybara.app_host = "http://example.com"
  end

  it "shows only tenant's properties" do
    property = create(:property, account: account, title: "Beach House")
    other_property = create(:property, title: "Mountain Cabin")

    visit properties_path

    expect(page).to have_content("Beach House")
    expect(page).not_to have_content("Mountain Cabin")
  end
end

Security Considerations

1. Never Trust User Input for Tenant ID

Bad:

def create
  Property.create!(property_params)
end

def property_params
  params.require(:property).permit(:title, :price, :account_id) # ❌ DANGER
end

Why bad? User can set account_id to another tenant's ID.

Good:

def create
  property = Property.new(property_params)
  # account_id set automatically from current_tenant
  property.save!
end

def property_params
  params.require(:property).permit(:title, :price) # ✅ No account_id
end

2. Validate Belongs-To Associations

class Inquiry < ApplicationRecord
  acts_as_tenant :account
  belongs_to :property

  validate :property_belongs_to_account

  private

  def property_belongs_to_account
    return unless property
    unless property.account_id == account_id
      errors.add(:property, "must belong to the same account")
    end
  end
end

3. Admin Controllers Need Extra Care

class Admin::BaseController < ApplicationController
  before_action :require_super_admin
  skip_before_action :set_tenant # Don't auto-scope for admins

  private

  def require_super_admin
    redirect_to root_path unless current_user&.super_admin?
  end
end

Performance Optimization

Database Indexes

Critical: Index all account_id columns

add_index :pwb_properties, :account_id
add_index :pwb_properties, [:account_id, :created_at]
add_index :pwb_properties, [:account_id, :price]

Check your queries:

EXPLAIN ANALYZE SELECT * FROM pwb_properties WHERE account_id = 1 AND price > 100000;

Look for Index Scan (good) vs. Sequential Scan (bad).

Eager Loading

Problem: N+1 queries across tenant boundaries

Bad:

properties = Property.all
properties.each do |property|
  puts property.user.name # N+1 query
end

Good:

properties = Property.includes(:user).all
properties.each do |property|
  puts property.user.name # Single query
end

Caching

Tenant-specific cache keys:

class Property < ApplicationRecord
  acts_as_tenant :account

  def cache_key
    "account:#{account_id}/properties/#{id}-#{updated_at.to_i}"
  end
end

Common Pitfalls

1. Forgetting to Set Tenant in Console

# rails console
Property.all
# => ActsAsTenant::Errors::NoTenantSet

# Fix:
account = Account.first
ActsAsTenant.current_tenant = account
Property.all # Works now

2. Migrations with Existing Data

Problem: Adding account_id NOT NULL to existing table fails.

Solution:

class AddAccountIdToProperties < ActiveRecord::Migration[8.0]
  def up
    add_reference :pwb_properties, :account, foreign_key: true # nullable first

    # Assign to default account or prompt admin to assign
    default_account = Account.first
    Property.update_all(account_id: default_account.id)

    # Now make it NOT NULL
    change_column_null :pwb_properties, :account_id, false
  end

  def down
    remove_reference :pwb_properties, :account
  end
end

3. Seeds with Multi-Tenancy

# db/seeds.rb

# Create accounts first
account1 = Account.create!(name: "Agency One", subdomain: "agency1")
account2 = Account.create!(name: "Agency Two", subdomain: "agency2")

# Set tenant for seeding
ActsAsTenant.current_tenant = account1

Property.create!(title: "Downtown Condo", price: 250_000)
Property.create!(title: "Suburban House", price: 450_000)

# Switch tenant
ActsAsTenant.current_tenant = account2

Property.create!(title: "Beach Villa", price: 1_200_000)

Migration from Non-Tenanted App

Scenario: You have an existing Rails app and want to add multi-tenancy.

Step 1: Add acts_as_tenant Gem

gem 'acts_as_tenant'
bundle install

Step 2: Create Tenant Model

rails generate model Account name:string subdomain:string:uniq
rails db:migrate

Step 3: Add Tenant References

rails generate migration AddAccountIdToAllModels

# Edit migration:
def change
  add_reference :properties, :account, foreign_key: true
  add_reference :users, :account, foreign_key: true
  add_reference :inquiries, :account, foreign_key: true
  # ... for all tenant-scoped models
end

rails db:migrate

Step 4: Assign Existing Data to Default Tenant

# db/migrate/[timestamp]_assign_default_tenant.rb
class AssignDefaultTenant < ActiveRecord::Migration[8.0]
  def up
    default_account = Account.create!(
      name: "Default Account",
      subdomain: "default"
    )

    Property.update_all(account_id: default_account.id)
    User.update_all(account_id: default_account.id)
    Inquiry.update_all(account_id: default_account.id)

    # Now make NOT NULL
    change_column_null :properties, :account_id, false
    change_column_null :users, :account_id, false
    change_column_null :inquiries, :account_id, false
  end
end

Step 5: Update Models

class Property < ApplicationRecord
  acts_as_tenant :account
  # ... existing code
end

Step 6: Update Controllers

class ApplicationController < ActionController::Base
  set_current_tenant_through_filter
  before_action :set_tenant

  def set_tenant
    account = Account.find_by!(subdomain: request.subdomain || 'default')
    set_current_tenant(account)
  end
end

Step 7: Test Thoroughly

# Create test account
rails console
account = Account.create!(name: "Test", subdomain: "test")

# Visit test.localhost:3000
# Verify queries are scoped

Conclusion

acts_as_tenant makes Rails 8 multi-tenancy straightforward:

Simple setup: One gem, one tenant_id column
Automatic scoping: No manual filtering
Thread-safe: Works with concurrent requests
Rails 8 compatible: Turbo, Solid Queue, modern features

Best for:
- SaaS apps with isolated customer data
- Platforms serving multiple organizations
- Apps with subdomain/domain-based tenancy

Not ideal for:
- Apps needing total data isolation (consider separate DBs)
- Simple apps with single organization
- Apps where "tenants" share most data


Resources

  • acts_as_tenant GitHub: https://github.com/ErwinM/acts_as_tenant
  • PropertyWebBuilder implementation: https://github.com/etewiah/property_web_builder
  • Rails 8 guides: https://edgeguides.rubyonrails.org
  • Multi-tenancy patterns: https://martinfowler.com/articles/multi-tenant.html

Questions? Open a GitHub discussion or find me on Twitter [@etewiah]

PropertyWebBuilder — Open-source Rails engine for real estate websites

GitHub · Articles · Twitter