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