---
title: "Introducing rails-tenantify: Row-Level Multi-Tenancy for Rails 7+"
published: true
description: "A modern, safe, and robust row-level multi-tenancy gem for Ruby on Rails. Prevent data leaks, protect bulk writes, and preserve tenant context in background jobs."
tags: rails, ruby, opensource, saas
---
## The Problem
Every multi-tenant SaaS app eventually needs to answer the same questions:
* How do we make sure School A never sees School B's data?
* How do we scope every query to the right organization?
* How do we keep tenant context alive in background jobs and Sidekiq retries?
* How do we stop a careless `update_all` from wiping another tenant's rows?
The typical answer is *"use acts_as_tenant"* or *"switch to Apartment."* But in modern Rails development, that often means:
* Fighting unmaintained APIs on Rails 7+
* Losing tenant context when a background job retries
* Dealing with schema-per-tenant complexity (Apartment) and heavy DevOps overhead
* Rolling your own `default_scope` and crossing your fingers that nobody calls `unscoped`
For most Rails apps, you just need **row-level tenancy**: one database, one `organization_id` column, and strict scoping. The pattern is simple. Getting it **safe** in production is not.
---
## What I Built
**`rails-tenantify`** is a Ruby gem that adds row-level multi-tenancy directly to your Rails models and controllers. No external services, no extra databases per tenant—just your own PostgreSQL (or SQLite in dev).
ruby
class Project < ApplicationRecord
include Tenantify::Scoped
belongs_to_tenant :organization
end
### Set the tenant once per request
ruby
class ApplicationController < ActionController::Base
set_tenant_by :subdomain # acme.yourapp.com → Organization
end
### Everything scopes automatically
ruby
Tenantify.current_tenant = current_organization
Project.all # Only this org's projects
Project.create!(name: "Q2 Roadmap") # organization_id is set automatically
### Switch context safely for admins or scripts
ruby
Temporarily switch context
Tenantify.switch_to(other_org) do
Project.count # Scoped to other_org
end
Intentional bypass
Tenantify.without_tenant do
Project.delete_all
end
---
## How it compares to `acts_as_tenant`
While `acts_as_tenant` pioneered this pattern, many teams hit walls on modern Rails—especially around background jobs, bulk SQL, and strict safety. `rails-tenantify` was built from the ground up for Rails 7+ with those gaps in mind.
| Feature | `acts_as_tenant` | `rails-tenantify` |
| --- | --- | --- |
| **Rails 7+ Focus** | Partial | **Yes** |
| **Subdomain Resolver** | DIY | `set_tenant_by :subdomain` |
| **API Header Resolver** | DIY | `set_tenant_by :header` |
| **Sidekiq Retry + Tenant** | Known issues | **Tenant ID preserved in job payload** |
| **`update_all` / `delete_all**` | Unreliable protection | **Raises unless explicitly scoped** |
| **Cross-Tenant Associations** | Manual checks | **Built-in validation** |
| **Unsafe Tenant Swap** | No audit | Options: `:log`, `:raise`, or `:ignore` |
| **Test Helpers** | Partial | Native `with_tenant` / `without_tenant` |
---
## How it compares to `Apartment`
`Apartment` uses separate schemas or databases per tenant. That offers strong isolation, but you pay a steep price in migrations, backups, and connection management.
`rails-tenantify` keeps one database and scopes via a foreign key—the exact tradeoff most B2B SaaS products actually want.
| Feature | `Apartment` | `rails-tenantify` |
| --- | --- | --- |
| **Isolation Model** | Schema / DB per tenant | Row-level Foreign Key |
| **Migrations** | Per-tenant complexity | Standard Rails migration path |
| **Ops Overhead** | Higher | **Lower** |
| **Best Fit For** | Strict regulatory separation | Typical B2B SaaS |
---
## Key Features
### 1. Multi-Resolution Strategies
Resolve your tenant out of the box via subdomains or API headers:
ruby
Subdomain resolution (excludes common marketing/admin routes)
set_tenant_by :subdomain, exclude: %w[www admin]
API resolution for mobile or JSON clients
set_tenant_by :header, header: "X-Tenant-ID"
Or resolve manually based on the session:
ruby
before_action do
Tenantify.current_tenant = current_user.organization if user_signed_in?
end
### 2. Bulletproof Background Jobs
The tenant automatically survives enqueueing and execution via standard **ActiveJob**:
ruby
class ExportJob < ApplicationJob
def perform
Tenantify.current_tenant # Automatically set to the org that enqueued the job
Project.find_each { |p| p.update!(exported: true) }
end
end
*Note: If you use **Sidekiq**, the middleware registers automatically and injects the `tenant_id` straight into the job hash.*
### 3. Bulk-Write Protection
Avoid the ultimate multi-tenancy footgun: accidental database-wide updates.
ruby
Project.update_all(status: "archived") # Raises an error if not explicitly scoped to current tenant
Project.unscoped.update_all(...) # Raises Tenantify::TenantMismatchError
Explicitly allowed bypass:
Tenantify.without_tenant do
Project.update_all(status: "migrated")
end
### 4. Cross-Tenant Association Checks
Prevent data leaking through relationships.
ruby
task.project = project_from_other_org
task.valid? # => false (Errors: "belongs to a different tenant")
### 5. Override Auditing
Catch risky code that unexpectedly swaps tenants mid-request.
ruby
Tenantify.configure { |c| c.audit_overrides = :raise }
Tenantify.current_tenant = org_a
Tenantify.current_tenant = org_b # => Raises Tenantify::TenantOverrideError
---
## Installation & Setup
Add the gem to your `Gemfile`:
ruby
gem "rails-tenantify", "~> 0.1.2", require: "rails-tenantify"
Run `bundle install` and create an initializer at `config/initializers/tenantify.rb`:
ruby
Tenantify.configure do |config|
config.tenant_model = "Organization"
config.on_tenant_not_found = :raise # Options: :raise | :redirect | :null_tenant
config.audit_overrides = :log # Options: :log | :raise | :ignore
end
Add the tenant foreign key to your tables:
bash
rails g model Organization name:string subdomain:string:uniq
rails g migration AddOrganizationToProjects organization:references
rails db:migrate
> ⚠️ **Note on Naming:** The gem is published as `rails-tenantify` on RubyGems but required as `"rails-tenantify"`. (The old, legacy `tenantify` name on RubyGems belongs to an unrelated, abandoned library from 2016).
### Built-In Testing Helpers (RSpec Friendly)
ruby
spec/rails_helper.rb
RSpec.configure { |c| c.include Tenantify::TestHelpers }
In your specs:
it "scopes creation automatically" do
with_tenant(org_a) do
exam = Exam.create!(title: "Midterm")
expect(exam.organization).to eq(org_a)
end
end
---
## Roadmap: Subdomains vs Custom Domains
* **Supported today:** Tenant subdomains on your primary application host (e.g., `greenwood.yourapp.com` → `Organization.find_by(subdomain: "greenwood")`).
* **Coming soon:** Full custom apex domains (e.g., `greenwood.edu` resolving via host-based lookups and custom DNS mapping).
---
## Why I Built This
I was building a multi-tenant Online Exam System in Rails—multiple schools, each with their own admins, teachers, exams, and students. I needed Greenwood School to absolutely *never* see Riverside School's data. Not in the UI, not in a background job, and definitely not via a copy-pasted `update_all` statement in a production console.
I didn't want the DevOps nightmare of managing a schema-per-school, and I didn't want to fork unmaintained tenancy gems. I wanted to run `bin/rails tenantify:verify` after my seeds ran and sleep soundly knowing data isolation was working perfectly.
So, I built `rails-tenantify`, integrated it into production, and open-sourced v0.1.2.
## Links & Feedback
* **GitHub:** [github.com/sghani001/rails-tenantify](https://www.google.com/search?q=https://github.com/sghani001/rails-tenantify)
* **RubyGems:** [rubygems.org/gems/rails-tenantify](https://www.google.com/search?q=https://rubygems.org/gems/rails-tenantify)
If you give it a try, I'd love to hear your thoughts! Open an issue, leave a comment below, or drop a ⭐ on the GitHub repository.
plaintext
Top comments (0)