Rails Multi-Tenancy: One App, Multiple Customers
Learn Rails multi-tenancy: tenant isolation, shared tables, subdomains, jobs, caching, testing, and gems for secure SaaS apps.
By Jean Emmanuel Cadet • 29 min read
Imagine you have built a project management tool in Rails. Your first customer loves it, then ten more companies sign up, then a hundred. Each company expects its own workspace and users, and a guarantee that nobody outside the company can see its projects. You could deploy a separate copy of the app for every customer, but that gets expensive and slow to maintain. Most SaaS teams run one codebase that serves all of their customers instead.
That approach is called multi-tenancy, and Rails multi-tenancy is the set of design decisions that make it safe. Team chat tools, online store builders, and project trackers all share this shape: many organizations log in to the same application, yet each one sees only its own data.
The central challenge is easy to state and easy to get wrong: share the infrastructure, never share the data. One forgotten where clause can show one customer another customer’s records.
In this guide, you will learn how Ruby on Rails multi-tenant applications are designed, from database layout to background jobs. We compare three database approaches (shared tables, separate schemas, and separate databases). Then we build a practical shared-table example with an Organization model, resolve the current tenant from a subdomain, and enforce tenant isolation with ActiveSupport::CurrentAttributes, explicit scoping, and authorization. We finish with jobs, mailers, files, caching, Action Cable, APIs, gems, testing, and a decision framework.
All examples target Rails 8.1 conventions.
What Is Multi-Tenancy in Rails Applications?
Multi-tenancy is an architecture where a single running application serves multiple customers, called tenants, while keeping each tenant’s data and configuration logically separated.
What a Tenant Represents
A tenant is the customer boundary in your product. Depending on the product, it might be a company, an organization, a team, a workspace, or a customer account. In this article the tenant is an Organization.
A tenant is not the same thing as a user. Users belong to tenants. Depending on your product, one user may belong to a single organization or to several (a freelancer working with multiple clients, for example). Decide this early, because it shapes your authentication and membership model.
Single-Tenant vs Multi-Tenant Applications
In a single-tenant setup, each customer gets a dedicated deployment, usually with its own database. Isolation is strong because nothing is shared, but you operate many copies of the system.
In a multi-tenant setup, one application deployment serves every customer. The application, not the infrastructure, decides which data each request may touch.
Benefits and Trade-Offs
Multi-tenancy has clear benefits:
- One codebase and one deployment pipeline to maintain.
- One migration path instead of one per customer.
- Lower infrastructure overhead per customer.
- New features reach every customer at the same time.
It also has real costs:
- Blast radius: a bug in tenant scoping can affect every customer at once.
- Noisy neighbors: one heavy tenant can slow down others if you do not plan for it.
- Per-tenant operations: exporting, restoring, or deleting a single customer’s data is harder when everyone shares tables.
- Customization limits: per-customer schema differences are difficult in shared structures.
Why Tenant Isolation Is a Security Requirement
Tenant isolation is not an architectural preference you can trade away for convenience. When one customer sees another customer’s data, that is a data breach. It can violate contracts, privacy obligations, and, most importantly, customer trust.
Keep this principle in mind for the rest of the article: isolation must be enforced by the system, not remembered by developers. Every design choice below is about making the safe path the default path.
Multi-Tenancy Architecture Patterns for Rails
There are three common ways to organize tenant data in a database. Each one trades isolation against complexity.
Shared Database, Shared Tables (tenant_id Column)
All tenants live in the same tables, and each tenant-owned row carries a foreign key such as organization_id. Every query filters by that column.
This is the simplest approach to build and operate. There is one schema, one set of migrations, one connection pool, and standard Active Record features work normally. The downside is that isolation lives in your application code (unless you add database-level protections, covered below). A missing filter is a leak.
Shared Database, Separate Schemas
In PostgreSQL, a schema is a namespace inside a database. With this pattern, each tenant gets its own schema containing its own copy of the tables, and the application switches the active schema (through the connection’s search_path) per request.
Isolation is stronger, because a query running in one tenant’s schema cannot accidentally read another tenant’s table. The cost is operational: every migration must run against every schema, and tooling such as backups, schema dumps, and analytics queries becomes more involved as the number of tenants grows. Rails does not ship a built-in tenant switcher for schemas, so you typically rely on a library or custom code.
Separate Database for Each Tenant
Each tenant gets a dedicated database. This offers the strongest data-level separation and makes per-tenant backup, restore, and deletion straightforward. It also allows tenants to live on different servers or regions.
It is also the most complex. Rails supports multiple databases and horizontal sharding (connects_to and connected_to(shard:)), but shards are generally declared in configuration, so provisioning new tenants dynamically takes additional work. Migrations, connection management, and monitoring multiply with the tenant count.
Comparing the Three Approaches
Factor | Shared tables | Separate schemas | Separate databases |
|---|---|---|---|
Data isolation | Application-enforced (database-enforced with RLS) | Database-enforced namespace | Strongest data separation |
Implementation complexity | Lowest | Moderate | Highest |
Migrations | Run once | Run per schema | Run per database |
Per-tenant backup and restore | Difficult | Moderate | Straightforward |
Cross-tenant reporting | Easy | Harder | Hardest |
Fits well when | Many small and medium tenants | Fewer tenants with stronger isolation needs | Strict compliance or very large tenants |
For most small and medium SaaS applications, shared tables with an organization_id column is the pragmatic starting point. It matches how Rails works out of the box, and it is the approach we build in this article. Stronger isolation is worth its extra complexity when contracts, regulations, or customer expectations demand it, and we will return to that decision near the end.
Three Levels of Isolation: Application, Database, and Infrastructure
It helps to separate where isolation is enforced:
- Application-level isolation: your Rails code scopes queries and checks permissions. This is what shared tables rely on. It is flexible but depends on consistent code.
- Database-level isolation: the database itself prevents cross-tenant access. Separate schemas, separate databases, and PostgreSQL row-level security (RLS) all fall into this category. A bug in the Rails layer is less likely to become a leak.
- Infrastructure-level isolation: tenants run on separate servers, containers, networks, storage buckets, or encryption keys. This is the heaviest option and usually appears in enterprise or regulated contexts.
These levels can be combined. A shared-table app can add RLS as a safety net, and a large customer can be moved onto dedicated infrastructure while everyone else stays shared.
Adding a Database Safety Net With Row-Level Security
If you use PostgreSQL with shared tables, row-level security lets the database refuse to return rows from other tenants, even if the application forgets a filter:
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY projects_tenant_isolation ON projects
USING (organization_id = current_setting('app.current_organization_id', true)::bigint)
WITH CHECK (organization_id = current_setting('app.current_organization_id', true)::bigint);
The application sets app.current_organization_id for each request or job, typically with a transaction-local set_config(..., true) so the value cannot leak through a pooled connection. If the setting is missing, the policy matches no rows.
Know the limits before adopting this. Superusers and roles with the BYPASSRLS attribute ignore policies, and table owners ignore them unless you use FORCE ROW LEVEL SECURITY, so the app should connect as a restricted role. Policies are also not represented in schema.rb, so you would switch to config.active_record.schema_format = :sql. RLS is a strong extra layer, but it adds real complexity, so treat it as an optional upgrade, not a starting requirement.
Designing a Multi-Tenant Rails Database
Let’s build the shared-table design for a small project management SaaS. The tenant is Organization. Projects and tasks belong to an organization. Users are global records that join organizations through memberships. (Some products keep users separate per tenant instead. Global users make multi-organization membership easy, but they require a membership check on every request.)
Creating the Tenant Table
class CreateOrganizations < ActiveRecord::Migration[8.1]
def change
create_table :organizations do |t|
t.string :name, null: false
t.string :subdomain, null: false
t.timestamps
end
add_index :organizations, :subdomain, unique: true
end
end
The unique index on subdomain matters because the subdomain will identify the tenant. A model validation alone is not enough, since two simultaneous requests can pass validation and both insert. For more on writing safe schema changes, see our guide to Rails database migrations best practices.
Adding Tenant-Owned Tables
class CreateProjectsAndTasks < ActiveRecord::Migration[8.1]
def change
create_table :projects do |t|
t.references :organization, null: false, foreign_key: true
t.string :name, null: false
t.timestamps
end
add_index :projects, [:organization_id, :name], unique: true
add_index :projects, [:organization_id, :id], unique: true
create_table :tasks do |t|
t.references :organization, null: false, foreign_key: true
t.bigint :project_id, null: false
t.string :title, null: false
t.datetime :completed_at
t.datetime :archived_at
t.timestamps
end
add_index :tasks, [:organization_id, :project_id]
add_foreign_key :tasks, :projects,
column: [:organization_id, :project_id],
primary_key: [:organization_id, :id]
end
end
Several decisions here are deliberate:
null: falseonorganization_idmeans a row can never exist without an owner.foreign_key: truemakes the database reject references to organizations that do not exist.- Composite indexes start with
organization_id, because nearly every query filters by tenant first. Uniqueness is also scoped per tenant, so two organizations can each have a project named “Roadmap.” - The composite foreign key (PostgreSQL) guarantees that a task’s project belongs to the same organization as the task. Without it, a bug could attach a task from one tenant to another tenant’s project. Rails supports composite foreign keys in migrations since 7.1.
If you want to go deeper on why these indexes help, read database indexes in Rails: a practical guide.
Defining the Models and Associations
class Organization < ApplicationRecord
RESERVED_SUBDOMAINS = %w[www app admin api mail support status assets cdn help billing].freeze
has_many :memberships, dependent: :destroy
has_many :users, through: :memberships
has_many :projects, dependent: :destroy
has_many :tasks, dependent: :destroy
normalizes :subdomain, with: ->(value) { value.strip.downcase }
validates :name, presence: true
validates :subdomain, presence: true,
uniqueness: true,
format: { with: /\A[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\z/ },
exclusion: { in: RESERVED_SUBDOMAINS }
end
class Membership < ApplicationRecord
belongs_to :organization
belongs_to :user
enum :role, { member: 0, admin: 1 }, default: :member
validates :user_id, uniqueness: { scope: :organization_id }
end
class Project < ApplicationRecord
belongs_to :organization
has_many :tasks, dependent: :destroy
end
class Task < ApplicationRecord
belongs_to :organization
belongs_to :project
before_validation { self.organization_id ||= project&.organization_id }
validate :project_belongs_to_same_organization
private
def project_belongs_to_same_organization
return if project.nil? || project.organization_id == organization_id
errors.add(:project, "must belong to the same organization")
end
end
If associations are new to you, our article on how Active Record associations work covers the fundamentals. The important idea here is that organization.projects is already a tenant-scoped query, and we will lean on that heavily.
tenant_id or organization_id?
Both work. What matters is consistency. If the tenant concept in your product is an organization, call the column organization_id everywhere. Mixed names such as account_id on one table and company_id on another make scoping rules harder to review and easier to get wrong. Libraries such as ActsAsTenant do not care which name you pick, so choose the name that matches your product language.
Handling Global Records
Some tables are not tenant-owned: plan definitions, country lists, or feature flags shared by everyone. Keep these explicit and few. Do not add a nullable organization_id to a table just to let rows be “sometimes global” unless you truly need system-provided defaults (for example, built-in templates that tenants can copy). If you do, make queries deliberate:
Template.where(organization_id: [nil, Current.organization.id])
Maintain a short written list of global tables so reviewers know which models are intentionally unscoped.
A Column Alone Does Not Guarantee Isolation
Adding organization_id is only the storage half of the solution. The column does nothing until every read and write respects it. The next sections focus on that enforcement.
Identifying the Current Tenant in Rails
Before you can scope data, you must know which tenant is making the request. That decision has to be made early, from information the user cannot forge.
Common Tenant Identification Strategies
- Subdomains such as
acme.example.com. This is the most common pattern, and it is easy for customers to understand. - Custom domains such as
projects.acme.com, mapped to a tenant record. These need extra care around ownership verification and TLS certificates. - Tenant selection after authentication, where a signed-in user picks one of their organizations, or the organization appears in the URL path (
/orgs/acme/projects). This works well when users belong to several organizations.
Whichever you choose, the rule is the same: the identifier only nominates a tenant. The server must verify that the current user is allowed to act in it.
A Subdomain-Based Approach
First, the routes. We keep the marketing site on the bare domain (or www) and the application on tenant subdomains. A routing constraint is one way to enforce that:
# config/routes.rb
Rails.application.routes.draw do
constraints(subdomain: /.+/) do
resources :projects do
resources :tasks, only: %i[index create update destroy]
end
root "projects#index", as: :tenant_root
end
# Marketing and sign-up pages on the bare domain
root "pages#home"
end
Then a controller concern resolves the tenant from the request:
# app/controllers/concerns/current_organization.rb
module CurrentOrganization
extend ActiveSupport::Concern
included do
before_action :set_current_organization
end
private
def set_current_organization
subdomain = request.subdomain.to_s.downcase
organization = nil
unless subdomain.blank? || Organization::RESERVED_SUBDOMAINS.include?(subdomain)
organization = Organization.find_by(subdomain: subdomain)
end
return head(:not_found) unless organization
Current.organization = organization
end
end
request.subdomain extracts the subdomain from the host. By default Rails assumes a one-part top-level domain, so for hosts like acme.example.co.uk you must set config.action_dispatch.tld_length = 2. For local development, acme.lvh.me resolves to your own machine and works with the default setting (add config.hosts << ".lvh.me" in development.rb). If you prefer acme.localhost, set tld_length to 0 in development.
The concern stores the result in Current, a small class we define in the next section.
Resolving the Tenant Safely
A few rules keep this step safe:
- Look the tenant up in your database. Never build a tenant from the raw subdomain string.
- Treat unknown tenants as
404. Returning the same response for “does not exist” and “not yours” avoids confirming which subdomains are real. - Handle missing subdomains explicitly. A request to the bare domain should go to your marketing site, not to tenant code.
- Reserve system subdomains (
www,api,admin, and so on) and block them in model validation, as theOrganizationmodel above does. - Normalize case. Hostnames are case-insensitive, so downcase before lookup.
Custom Domains
For custom domains, the lookup changes from subdomain to full host, for example Organization.find_by(custom_domain: request.host). Before activating a custom domain, verify that the customer actually controls it (a DNS TXT record challenge is a common approach), store it normalized to lowercase, and enforce uniqueness at the database level. Also review Rails’ host authorization (config.hosts) so unexpected hosts are rejected before they reach your tenant logic.
What Not to Trust
Never establish tenant identity from an arbitrary request parameter or header such as params[:organization_id] or X-Organization-Id, and never from unvalidated host information. Attackers control those values. If your design does take an organization slug from the URL path, treat it as a claim, then confirm that the authenticated user has a membership in that organization on every request. If you are still choosing how to sign users in, see Devise vs custom authentication in Rails.
Implementing Tenant Isolation in Rails
Now we make the current tenant available and use it in every query.
Storing the Tenant With ActiveSupport::CurrentAttributes
ActiveSupport::CurrentAttributes provides thread-isolated, per-request storage, and Rails resets it around each request and each job run through Active Job. If you used Rails’ built-in authentication generator, you already have a Current class with a session attribute. Extend it:
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :session, :organization, :membership
delegate :user, to: :session, allow_nil: true
end
Use Current sparingly. It is global state, so keep it at the edges of your application (controllers, channels, job entry points) and pass explicit objects into your inner code.
Putting the Pieces in the Right Order
Three separate questions must be answered in order: which tenant is this, who is the user, and may this user act in this tenant?
class ApplicationController < ActionController::Base
include CurrentOrganization # 1. Which tenant is this?
include Authentication # 2. Who is the user? (Rails 8 authentication generator)
before_action :require_membership # 3. May this user act in this tenant?
private
def require_membership
Current.membership = Current.organization.memberships.find_by(user: Current.user)
head :not_found unless Current.membership
end
end
Callbacks run in the order they are declared, which is why include order matters. Pages that must be reachable without a membership, such as the sign-in page, should explicitly skip_before_action :require_membership.
Scoping Queries Through the Tenant
The simplest and clearest isolation technique is to start every query from the current organization:
class ProjectsController < ApplicationController
def index
@projects = Current.organization.projects.order(:name)
end
def show
@project = Current.organization.projects.find(params[:id])
end
def create
@project = Current.organization.projects.build(project_params)
if @project.save
redirect_to @project
else
render :new, status: :unprocessable_entity
end
end
private
def project_params
params.expect(project: [:name])
end
end
Three details protect you here. Current.organization.projects.find(params[:id]) raises ActiveRecord::RecordNotFound for another tenant’s project, which Rails renders as a 404 in production. build through the association sets organization_id automatically. And project_params never permits organization_id, so a user cannot reassign records by tampering with a form. For more on writing efficient scoped queries, see how to optimize Active Record queries.
Keeping Models and Services Decoupled
Controllers can read Current, but models and service objects are easier to test and reuse when they receive the tenant explicitly:
class Tasks::Complete
def initialize(organization:)
@organization = organization
end
def call(task_id)
@organization.tasks.find(task_id).update!(completed_at: Time.current)
end
end
Tasks::Complete.new(organization: Current.organization).call(params[:id])
This object works the same way from a controller, a job, or a console session, because it never reaches into global state.
Why default_scope Is a Risky Primary Defense
It is tempting to write this and consider the problem solved:
class Project < ApplicationRecord
default_scope { where(organization_id: Current.organization&.id) }
end
It looks automatic, but it is a weak foundation for security:
unscoped,unscope(:where),find_by_sql, and raw SQL bypass it.- Bulk methods such as
insert_allandupsert_alldo not go through your default scope in the way you might expect. - Implicit conditions can surprise you: new records inherit attributes from the scope’s
where, which can hide bugs. - Where
Current.organizationisnil(console, rake tasks, background jobs), behavior depends on how you wrote it. A conditional version silently turns the filter off. - It becomes hard to see, in code review, whether a query is safe.
Libraries such as ActsAsTenant do use default_scope, but they add protections such as raising when no tenant is set. If you write it yourself, treat default_scope as a convenience layer, never as the only barrier. Explicit scoping through Current.organization plus authorization checks is easier to read and audit.
Preventing Cross-Tenant Data Leaks
Most cross-tenant leaks are not exotic. They are ordinary queries that forgot the tenant.
An Unsafe Query and Its Scoped Alternative
# Unsafe: any signed-in user can open any task by guessing an ID
def show
@task = Task.find(params[:id])
end
# Safe: scoped through the tenant and the parent resource
def show
@project = Current.organization.projects.find(params[:project_id])
@task = @project.tasks.find(params[:id])
end
The unsafe version is a classic insecure direct object reference. Switching to UUIDs would only make guessing harder. It does not fix the missing scope.
Where Leaks Hide
- Direct record lookups like
Model.find(params[:id]). - Nested resources where the controller loads the child by ID and ignores the parent in the URL.
- Mass assignment that permits
organization_idor lets users pass IDs of other tenants’ records (for exampleproject_idon a task). - Aggregates and dashboards such as
Project.countorTask.where(...)in views, helpers, and admin pages. - Background jobs that look up records by ID with no tenant context.
- Webhooks, search indexes, exports, and reports, which often live outside the normal controller stack.
- Console scripts and rake tasks, where there is no request at all.
Authentication, Tenant Resolution, Scoping, and Authorization
These four layers solve different problems, and none replaces another:
Layer | Question it answers |
|---|---|
Authentication | Who is this user? |
Tenant resolution | Which organization is this request for? |
Scoping | Which rows may this query even see? |
Authorization | Is this user allowed to perform this action on this record? |
A user can be authenticated but not a member of the tenant. A member can be allowed to view a project but not delete it. Scoping makes cross-tenant access impossible by construction, while authorization handles roles within a tenant.
Authorization as an Extra Protection Layer
Pundit and Action Policy are well-known authorization libraries. Here is a Pundit policy that checks both tenant and role:
class ProjectPolicy < ApplicationPolicy
def show? = in_current_organization?
def update? = in_current_organization? && Current.membership&.admin?
def destroy? = update?
class Scope < ApplicationPolicy::Scope
def resolve
scope.where(organization: Current.organization)
end
end
private
def in_current_organization?
record.organization_id == Current.organization&.id
end
end
class ApplicationController < ActionController::Base
include Pundit::Authorization
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
private
def pundit_user = Current.user
end
verify_authorized and verify_policy_scoped raise an error if a controller action forgets to call authorize or policy_scope, which turns a silent omission into a loud failure during development and testing. Even with a policy in place, keep the scoped lookup (Current.organization.projects.find). Two independent checks are better than one.
Enforce Isolation at Every Entry Point
Browser controllers are only one door. API controllers, Action Cable channels, background jobs, mailers, webhooks, admin tools, and scripts all touch tenant data. Each one needs its own answer to “which tenant is this?” and each one must scope accordingly. A common review habit is to search the codebase for direct calls like Project.find, Task.where, and .unscoped and justify each one.
Tenant Context and Background Jobs
Background jobs run outside the request cycle, so the tenant that was “current” in the controller does not automatically exist when the job runs. Relying on Current.organization inside a job is unsafe: it may be nil, or, if you forward attributes through custom middleware, stale or wrong.
For a refresher on queues and retries, see our beginner’s guide to Active Job in Rails.
Pass the Tenant Explicitly
The safest pattern is to pass the organization (or its ID) as a job argument and establish context inside perform:
class ArchiveCompletedTasksJob < ApplicationJob
queue_as :default
discard_on ActiveJob::DeserializationError
def perform(organization)
Current.set(organization: organization) do
organization.tasks
.where.not(completed_at: nil)
.where(archived_at: nil)
.update_all(archived_at: Time.current)
end
end
end
ArchiveCompletedTasksJob.perform_later(Current.organization)
Active Job serializes Active Record objects with GlobalID, so passing organization works, and the job reloads it when it runs. If the organization was deleted before the job ran, Rails raises ActiveJob::DeserializationError, which discard_on handles gracefully.
Current.set applies the context for the block and restores the previous values afterward. The query itself still goes through organization.tasks, so isolation does not depend on global state.
When a job also needs a specific record, pass its ID and look it up through the tenant:
def perform(organization, project_id)
project = organization.projects.find(project_id)
# ...
end
This prevents a job from processing a project that does not belong to the stated organization, even if the wrong ID is enqueued by mistake.
Scheduled Jobs, Retries, and Fan-Out
Scheduled jobs (for example Solid Queue recurring tasks) have no request at all. A good pattern is a small “fan-out” job that enqueues one job per tenant:
class ArchiveAllOrganizationsJob < ApplicationJob
def perform
Organization.find_each do |organization|
ArchiveCompletedTasksJob.perform_later(organization)
end
end
end
This gives each tenant an independent job, so one tenant’s failure does not block the others and retries affect only that tenant. Because the tenant is part of the job’s arguments, a retry runs with the same tenant as the original attempt.
Avoiding Accidental Cross-Tenant Processing
Some queue integrations can be configured to carry CurrentAttributes into jobs automatically. That can be convenient, but verify exactly what your adapter does, and prefer explicit arguments for anything security-sensitive. A few rules to follow:
- Never write a job that takes only a record ID and uses an unscoped
find. - Do not store tenant context in global or class-level variables.
- Use
Current.set(or an ensure block) so context cannot linger after the job finishes. - Log the tenant ID with job executions so cross-tenant issues are traceable.
Tenant Context in Mailers, Storage, Caching, Action Cable, and APIs
Tenant context also matters in several less obvious parts of Rails.
Action Mailer
Mailers often run from jobs through deliver_later, so they should receive the tenant explicitly, not read Current. They also need tenant-specific URLs:
class DigestMailer < ApplicationMailer
def weekly(membership)
@organization = membership.organization
@user = membership.user
@projects_url = projects_url(host: "#{@organization.subdomain}.#{Rails.configuration.x.base_domain}")
mail to: @user.email_address, subject: "#{@organization.name}: your weekly digest"
end
end
Rails.configuration.x.base_domain is a custom setting you define yourself. Using the tenant’s own host in links means recipients land on the right tenant, and branding or sender details can come from the organization record.
Active Storage
Uploaded files need the same protection as database rows. By default, Active Storage serves blobs through signed URLs, and its Rails-provided controllers are publicly accessible to anyone who holds a valid URL. For sensitive documents, serve files through your own controller that authorizes access first:
class DocumentsController < ApplicationController
def show
document = Current.organization.documents.find(params[:id])
redirect_to rails_blob_path(document.file, disposition: "attachment")
end
end
Our Active Storage practical guide covers the fundamentals. For stronger separation, some teams use separate storage buckets or key prefixes per tenant, which is an infrastructure-level choice.
Caching
When cached data is tenant-specific, the cache key must include tenant identity:
def dashboard_stats
Rails.cache.fetch(["dashboard_stats", Current.organization.id], expires_in: 10.minutes) do
{ projects: Current.organization.projects.count,
open_tasks: Current.organization.tasks.where(completed_at: nil).count }
end
end
Fragment caching deserves the same care. Caching a record (cache project) is naturally safe because each record has its own key. The risk appears when you cache a page section that depends on the current tenant (branding, settings, a sidebar) without including the organization in the key: cache [Current.organization, "sidebar"]. Be equally careful with CDN and HTTP caching so tenant pages are not served publicly across hosts. Our guide on fragment caching in Rails explains how keys are built.
Action Cable and WebSockets
A WebSocket connection is a long-lived entry point, so it must establish tenant and user once, at connection time:
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_organization, :current_user
def connect
self.current_organization = find_organization || reject_unauthorized_connection
self.current_user = find_member || reject_unauthorized_connection
end
private
def find_organization
Organization.find_by(subdomain: request.subdomain.to_s.downcase)
end
def find_member
session = Session.find_by(id: cookies.signed[:session_id])
user = session&.user
user if user && current_organization.memberships.exists?(user: user)
end
end
end
Then each channel authorizes the specific resource it streams:
class ProjectChannel < ApplicationCable::Channel
def subscribed
project = current_organization.projects.find_by(id: params[:project_id])
return reject unless project
stream_for project
end
end
The cookie lookup above matches the Rails 8 authentication generator. Adapt it to your own sign-in mechanism. Also configure allowed request origins for Action Cable.
API Endpoints
For APIs, derive the tenant from the credential, not from a client-supplied value:
module Api
module V1
class BaseController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate_api_key
private
def authenticate_api_key
authenticate_or_request_with_http_token do |token, _options|
api_key = ApiKey.find_by(token: token)
Current.organization = api_key&.organization
api_key.present?
end
end
end
end
end
Here each API key belongs to one organization, so the key itself identifies the tenant. (In production, store a digest of the token rather than the raw value.) If your API uses user-level tokens that span organizations, require the organization in the path and verify membership. Rails’ built-in rate_limit can also be keyed by tenant, for example rate_limit to: 1000, within: 1.hour, by: -> { Current.organization.id }, as long as it runs after tenant resolution.
Using Gems for Rails Multi-Tenancy
You do not need a gem to build multi-tenancy. The approach in this article uses only Rails. Still, libraries can reduce repetitive code. Here are the main options and what they are designed for.
ActsAsTenant (Shared Tables)
ActsAsTenant provides row-level multi-tenancy for the shared-table pattern. You declare acts_as_tenant :organization on a model, and the gem scopes queries to the current tenant and sets it on new records. It includes controller helpers for choosing the tenant (by subdomain or through a filter), ActsAsTenant.with_tenant for jobs and scripts, and a require_tenant option that raises when no tenant is set. It also has integrations for background job libraries such as Sidekiq. At the time of writing it is actively released, with recent 2.x versions published in October 2026.
Remember what it does and does not do. It uses default_scope internally, so the same caveats apply: raw SQL and unscoped can bypass it, and you still own tenant resolution, authorization, caching, files, and testing.
Apartment and ros-apartment (Schemas or Databases)
The original Apartment gem popularized schema-per-tenant multi-tenancy in Rails, but it is no longer maintained. Its community fork, ros-apartment, is a drop-in replacement that is maintained today (by CampusESP since 2024). It supports PostgreSQL schemas and separate databases, and uses Rack “elevators” to switch tenants by subdomain, domain, or host. Its README currently targets Rails 7.2 and newer. Always check the current README and release notes before adopting it, especially for your Rails and database versions.
Other Options
Rails itself supports multiple databases and horizontal sharding, which can be used for database-per-tenant designs. Citus Data’s activerecord-multi-tenant targets shared-table setups on Citus-distributed PostgreSQL, so it is mainly relevant if you plan to use that extension. Check its release history and Rails compatibility before relying on it.
Custom Implementation or a Gem?
A custom implementation is a good fit when your needs are simple, your team wants full visibility into how scoping works, or you are building a small shared-table app where Current.organization.projects is enough. A gem is a good fit when you want a proven pattern, you need schema or database switching, or you want conventions that reduce the chance of an unscoped query.
Either way, no gem guarantees isolation. You are still responsible for authorization, jobs, uploads, caches, and tests.
Testing a Multi-Tenant Rails Application
Tenant isolation is a security property, and security properties need dedicated tests. Ordinary feature tests typically sign in as one user in one tenant and never try to cross the boundary, so they will not catch a missing scope.
Start with fixtures for at least two organizations:
# test/fixtures/organizations.yml
acme:
name: Acme Inc.
subdomain: acme
globex:
name: Globex Corp.
subdomain: globex
Define projects, tasks, users, and memberships the same way (acme_roadmap and globex_roadmap for projects, for example).
Request Tests for Allowed and Forbidden Access
require "test_helper"
class TenantIsolationTest < ActionDispatch::IntegrationTest
setup do
host! "acme.example.com"
sign_in_as users(:acme_admin) # helper from the Rails authentication generator
end
test "member can view a project in their own organization" do
get project_path(projects(:acme_roadmap))
assert_response :success
end
test "member cannot view a project from another organization" do
get project_path(projects(:globex_roadmap))
assert_response :not_found
end
test "index lists only the current organization's projects" do
get projects_path
assert_includes response.body, projects(:acme_roadmap).name
assert_not_includes response.body, projects(:globex_roadmap).name
end
test "a member of another organization cannot use this subdomain" do
delete session_path
sign_in_as users(:globex_admin)
get projects_path
assert_response :not_found
end
end
Always test both directions: the permitted request succeeds, and the forbidden one fails. In Rails’ default test configuration, exceptions such as ActiveRecord::RecordNotFound are rendered as responses, which is why assert_response :not_found works.
Testing Tenant Resolution
class TenantResolutionTest < ActionDispatch::IntegrationTest
test "unknown subdomain returns not found" do
host! "nobody.example.com"
get projects_path
assert_response :not_found
end
test "reserved subdomain returns not found" do
host! "admin.example.com"
get projects_path
assert_response :not_found
end
end
Model-Level Tests
class TaskTest < ActiveSupport::TestCase
test "organization association only returns its own projects" do
assert_includes organizations(:acme).projects, projects(:acme_roadmap)
assert_not_includes organizations(:acme).projects, projects(:globex_roadmap)
end
test "task cannot belong to a project from another organization" do
task = Task.new(organization: organizations(:acme),
project: projects(:globex_roadmap),
title: "Sneaky")
assert_not task.valid?
assert_includes task.errors[:project], "must belong to the same organization"
end
end
Background Job Tests
class ArchiveCompletedTasksJobTest < ActiveJob::TestCase
test "only archives tasks for the given organization" do
ArchiveCompletedTasksJob.perform_now(organizations(:acme))
assert_not_nil tasks(:acme_done).reload.archived_at
assert_nil tasks(:globex_done).reload.archived_at
end
test "fan-out job enqueues one job per organization" do
assert_enqueued_jobs Organization.count, only: ArchiveCompletedTasksJob do
ArchiveAllOrganizationsJob.perform_now
end
end
end
For Action Cable, ActionCable::Connection::TestCase lets you assert that connections are rejected for unknown tenants or non-members. For authorization policies, add policy tests that check a user from another organization is denied. Every time you add a tenant-owned model, add at least one cross-tenant test for it.
Common Multi-Tenancy Mistakes and Best Practices
Mistakes to avoid
- Forgetting tenant scoping in a controller or service. Start queries from
Current.organization(or an explicitly passed organization), never from the bare model class. - Trusting user-supplied tenant identifiers. Resolve the tenant from the host or credential, and always verify membership.
- Relying entirely on
default_scope. Use it, if at all, as a convenience on top of explicit scoping. - Failing to isolate background jobs. Pass the tenant as an argument and look up records through it.
- Reusing cache keys across tenants. Include the organization in any key for tenant-specific data.
- Exposing files across organizations. Authorize file access through your own controllers when files are sensitive.
- Forgetting tenant context in Action Cable or API requests. Establish tenant and membership at connection or credential time.
- Failing to test cross-tenant access. Write the “forbidden” test, not just the “success” test.
- Choosing an architecture that is too complex. Separate databases for ten small customers can create more risk through operational mistakes than they remove.
Practical recommendations
- Put
organization_idon every tenant-owned table, withnull: false, a foreign key, and a leading composite index. - Keep a written list of global (non-tenant) tables and review it when the schema changes.
- Use database constraints, such as composite foreign keys, so bad data cannot be written even by buggy code.
- Search code reviews for
.find(params,.unscoped,find_by_sql, andwhere(id:. - Use authorization verification (
verify_authorized) so forgotten checks fail loudly. - Log the tenant ID with requests and jobs for debugging and incident response.
- Consider PostgreSQL row-level security as a defense-in-depth layer if your risk profile justifies it.
Choosing the Right Rails Multi-Tenancy Strategy
No approach is universally best. Here is how the options compare and how to decide.
- Shared tables are the simplest to build and operate, fit most small and medium SaaS products, and scale well operationally. Their weakness is that isolation relies on application correctness (plus RLS if you add it).
- Separate schemas give stronger database-enforced separation with moderate complexity, at the price of migrations and tooling that must account for every tenant.
- Separate databases give the strongest separation and the simplest per-tenant backup, restore, and deletion, but demand the most infrastructure and automation.
A Practical Decision Framework
Work through these questions in order:
- What do customers and contracts require? If compliance or contracts mandate physical or database-level separation, that requirement outranks convenience.
- How many tenants do you expect, and how different are they? Thousands of small tenants point toward shared tables. A handful of very large tenants may justify dedicated schemas or databases.
- Do tenants need different schemas or extensions? Per-tenant customization is much easier with separate schemas or databases.
- How often will you need per-tenant backup, restore, export, or deletion? Frequent needs make stronger separation more attractive.
- How much operational capacity does your team have? Every additional layer of isolation adds automation work, monitoring, and failure modes.
- What are your growth and data residency needs? Regional or data-location requirements may force separate databases for some customers.
A sensible default is to start with shared tables and organization_id everywhere, then add protections (authorization, RLS, tests) as risk grows. Hybrid setups are also common: most tenants share tables while a few large or regulated customers get dedicated infrastructure. A consistent organization_id design makes that later move more manageable, but plan it deliberately instead of assuming it will be easy.
Conclusion
Rails multi-tenancy lets one application serve many customers while keeping each customer’s data private. For most teams, the practical path is shared tables with an organization_id, a tenant resolved safely from the subdomain, and Current.organization as the starting point for every query.
The most important lesson is that tenant isolation is not a single feature. It has to hold across queries, authorization, background jobs, mailers, caching, files, Action Cable, and APIs, and it has to be proven by tests that deliberately try to cross the boundary. Choose an architecture that matches your product’s real requirements, not the most elaborate one available.
Your takeaway: design tenant isolation carefully from the beginning. Add the column, the constraints, the scoping, and the cross-tenant tests in your very first tenant-owned feature, and every feature after it will inherit a safer foundation.