Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 058 / 100

Multi-tenant SaaS with Rails for Ruby on Rails developers

Many customers in one app.

Expona Admin7 min read

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.

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

Notice 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:

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

Scope 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:

ruby
class Current < ActiveSupport::CurrentAttributes
  attribute :account, :user
end

Set this in your ApplicationController before every request:

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

Now, add a default scope to scoped models:

ruby
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 }
end

With this in place, any query automatically filters by the current account:

ruby
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:

ruby
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.

ruby
class Account < ApplicationRecord
  has_many :invoices
end

class Invoice < ApplicationRecord
  acts_as_tenant :account
  belongs_to :account
  belongs_to :user
end

Set the current tenant in your controller:

ruby
class ApplicationController < ActionController::Base
  before_action :set_tenant

  private

  def set_tenant
    if user_signed_in?
      ActsAsTenant.current_tenant = current_user.account
    end
  end
end

Now queries are automatically scoped:

ruby
Invoice.all
# Generates: SELECT * FROM invoices WHERE account_id = ?

# Attempting to unscope raises an error:
Invoice.unscoped.find(id)
# ActsAsTenant::Errors::TenantIsImmutable

The gem also validates associations to prevent cross-tenant data leaks:

ruby
invoice = Invoice.create!(
  account_id: Account.first.id,
  user_id: User.where(account: Account.second.id).first.id  # Raises error
)
# ActsAsTenant::Errors::TenantIsImmutable

This 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:

ruby
module TenantHelper
  def current_account_from_subdomain
    return nil if request.subdomain.blank? || request.subdomain == 'www'
    Account.find_by(slug: request.subdomain)
  end
end

Integrate it into your authentication flow:

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

Configure your Rails routes to enforce subdomains in development and production:

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

In 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:

ruby
127.0.0.1 localhost yourapp.local acme.yourapp.local widgets.yourapp.local

Common 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:

ruby
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:

ruby
# 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_id column to every customer-scoped table and index it first in composite keys.
  • Use Current.account to maintain thread-local tenant context and apply explicit scopes to queries.
  • Consider acts_as_tenant for 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles