I have spent years building Rails applications that serve multiple clients, or tenants, from a single codebase. The core challenge is keeping each tenant’s data separate and secure. Over time I learned that there is no single perfect solution. The right approach depends on your data sensitivity, infrastructure budget, and the technical maturity of your team. Let me walk you through seven patterns I have used in production. Each one has its own trade‑offs, and I will show you the code that makes it work.
The first pattern separates data at the database level. Each tenant gets its own physical database. You create a new database for every client. This gives you the strongest isolation possible. If a tenant’s database crashes, the others remain untouched. It also makes regulatory compliance easier because you can encrypt or back up individual databases independently.
Here is how I implement this in Rails. First, a Tenant model that knows how to build its own database configuration:
class Tenant < ApplicationRecord
has_many :tenant_connections, dependent: :destroy
def database_config
{
adapter: ENV['DB_ADAPTER'],
host: ENV['DB_HOST'],
port: ENV['DB_PORT'],
database: "tenant_#{id}",
username: ENV['DB_USERNAME'],
password: ENV['DB_PASSWORD']
}
end
def connection_pool
@connection_pool ||= ActiveRecord::Base.connection_pool_class.new(database_config)
end
end
I then use a TenantConnection class to wrap any block of code that needs to talk to a specific database:
class TenantConnection
def initialize(tenant)
@tenant = tenant
@pool = tenant.connection_pool
end
def with_connection(&block)
@pool.with_connection do |connection|
ActiveRecord::Base.connection_pool.current_pool = @pool
yield
ensure
ActiveRecord::Base.connection_pool.current_pool = nil
end
end
end
A Rack middleware reads the subdomain or a custom header, looks up the tenant, and switches the connection before the request reaches your controllers:
class TenantMiddleware
def initialize(app)
@app = app
end
def call(env)
tenant = identify_tenant(env)
return forbidden_response unless tenant
TenantConnection.new(tenant).with_connection do
@app.call(env)
end
end
def identify_tenant(env)
request = ActionDispatch::Request.new(env)
tenant_id = request.subdomain.presence || request.headers['X-Tenant-ID']
Tenant.find_by(id: tenant_id)
end
end
When I first used this pattern, the biggest surprise was the connection pool overhead. With dozens of tenants, you can end up with hundreds of open connections. You need to tune pool sizes and use a connection pool proxy. Also, running migrations across all databases becomes a manual script. For a small number of high‑value tenants, the isolation is worth the extra ops work.
The second pattern uses PostgreSQL schemas. Instead of a separate database, each tenant gets its own schema within the same database. This gives you good isolation while sharing the database server. You can manage a small number of schemas without too much pain.
In Rails, you switch the current schema by setting the search_path. I do this in middleware:
class TenantMiddleware
APP_SCHEMA = 'public'.freeze
def call(env)
tenant = identify_tenant(env)
return forbidden_response unless tenant
switch_schema(tenant.schema_name)
@app.call(env)
ensure
switch_schema(APP_SCHEMA)
end
def switch_schema(schema)
ActiveRecord::Base.connection.execute("SET search_path TO #{schema}, #{APP_SCHEMA}")
end
end
When you create a new tenant, you need to build a schema and run all your migrations inside it. I wrote a helper for that:
class TenantMigration
def self.create_tenant_schema(tenant)
schema = tenant.schema_name
ActiveRecord::Base.connection.execute("CREATE SCHEMA IF NOT EXISTS #{schema}")
migration_context = ActiveRecord::MigrationContext.new(
ActiveRecord::Migrator.migrations_paths,
ActiveRecord::SchemaMigration
)
migration_context.migrate
# Copy default data from public schema
copy_default_data(schema)
end
def self.copy_default_data(target_schema)
tables = %w[countries currencies settings]
tables.each do |table|
ActiveRecord::Base.connection.execute(
"INSERT INTO #{target_schema}.#{table} SELECT * FROM public.#{table}"
)
end
end
end
One thing I learned the hard way: be careful with foreign keys that reference the public schema. If a tenant record references a shared lookup table, the search_path might confuse the query planner. I usually keep shared lookup tables in the public schema and prefix them in migrations.
Schema‑per‑tenant works best when you have a few dozen tenants and your database server has enough memory for the schema cache. If you have thousands of tenants, the schema management becomes a bottleneck.
The third pattern is the most common: row‑level tenancy. You add a tenant_id column to every table that belongs to a tenant. Then you automatically filter all queries to the current tenant. Rails’ CurrentAttributes is perfect for holding the tenant context.
I start by adding a belongs_to :tenant to the ApplicationRecord and a default scope:
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
belongs_to :tenant, optional: false
default_scope -> { where(tenant_id: Current.tenant_id) }
end
The Current class stores the tenant and its ID in a thread‑safe way:
class Current < ActiveSupport::CurrentAttributes
attribute :tenant, :tenant_id
end
Then a middleware sets the current tenant before each request:
class TenantMiddleware
def call(env)
tenant = identify_tenant(env)
return forbidden_response unless tenant
Current.tenant = tenant
Current.tenant_id = tenant.id
@app.call(env)
ensure
Current.reset
end
end
I also add an around_action in the base controller so that even if you forget the middleware, the tenant is set:
class ApplicationController < ActionController::Base
around_action :set_tenant
def set_tenant(&block)
tenant = identify_tenant
Current.set(tenant: tenant, tenant_id: tenant.id) do
block.call
end
end
def current_tenant
Current.tenant
end
end
You must provide a way to bypass the default scope for admin queries and background jobs that cross tenants. I add a simple class method:
class Order < ApplicationRecord
def self.bypass_tenant
unscope(where: :tenant_id)
end
end
Row‑level tenancy is straightforward and scales well because you are only adding a WHERE clause. The danger is forgetting to scope a query. I have had embarrassing bugs where one tenant could see another’s orders. Using CurrentAttributes and a default scope helps, but never trust scopes alone – always validate the tenant in your controller actions too.
The fourth pattern uses table prefixes. Each tenant gets its own set of tables named like tenant_12_orders, tenant_12_users. This avoids separate schemas while still providing logical separation. Rails does not support this natively, so you need a custom connection adapter that rewrites SQL.
I built a minimal adapter that replaces table names with the prefixed version:
class TenantConnection < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
def execute(sql, name = nil)
sql = rewrite_sql(sql)
super
end
def rewrite_sql(sql)
sql.gsub(/\b(orders|users|products)\b/) { |match|
"#{Current.tenant.table_prefix}#{match}"
}
end
end
And the tenant model stores the prefix:
class Tenant
def table_prefix
"tenant_#{id}_"
end
end
This pattern is messy. Migrations do not work out of the box because ActiveRecord expects fixed table names. You have to write custom migration tasks that loop through tenants and run SQL on prefixed tables. I used it once for a small app that could not use schemas because we were on MySQL. I would only recommend it if you are stuck in a similar situation.
The fifth pattern is application‑level scoping. Instead of adding a tenant_id to every row, you access all data through a tenant root object, like an Organization. Every resource belongs to an organization, and you only query through the organization’s association.
Here is the model setup:
class Organization < ApplicationRecord
has_many :users, -> { where(organization_id: id) }
has_many :orders, through: :users
end
class User < ApplicationRecord
belongs_to :organization
has_many :orders
def self.within_organization(org_id)
where(organization_id: org_id)
end
end
In your controllers, you always scope through the current organization:
class OrdersController < ApplicationController
def index
@orders = current_organization.orders
end
private
def current_organization
@current_organization ||= Organization.find_by(subdomain: request.subdomain)
end
end
This pattern is lightweight and perfect for SaaS apps where every user belongs to exactly one organization. The downside is that you must be disciplined: never query Order.all directly. I once refactored a large app from raw queries to this pattern and it took weeks but eliminated all cross‑tenant leaks.
The sixth pattern is hybrid. You mix two or more of the previous approaches. For example, keep highly sensitive records like payment data in separate PostgreSQL schemas, while less sensitive metadata uses row‑level tenancy. This gives you the best of both worlds: strong isolation where you need it and shared infrastructure where you do not.
I implement it with abstract base classes:
class SensitiveRecords < ActiveRecord::Base
self.abstract_class = true
def self.table
"#{Current.tenant.schema_name}.#{table_name}"
end
def self.all
connection.execute("SELECT * FROM #{table}")
end
end
class User < SensitiveRecords
# Uses separate schema for this model
end
class Order < ApplicationRecord
# Standard row-level tenancy
belongs_to :tenant, optional: false
default_scope -> { where(tenant_id: Current.tenant_id) }
end
The sensitive models override the default table name resolution to point to the tenant’s schema. Everything else stays in the public schema with a tenant_id column. This pattern is complex, but it saved me when a client required PCI compliance for payment data while everything else could be less isolated.
The seventh pattern covers tenant provisioning – the automated process of creating a database, schema, or seed data for a new tenant. Without automation, onboarding a client takes hours and is error‑prone.
I wrote a TenantProvisioner that handles the whole process:
class TenantProvisioner
def initialize(tenant)
@tenant = tenant
end
def provision
ActiveRecord::Base.transaction do
create_schema_or_database
run_migrations
seed_default_data
create_admin_user
@tenant.update!(provisioned_at: Time.current)
end
end
def create_schema_or_database
if isolated_database?
create_database
else
create_schema
end
end
def run_migrations
if isolated_database?
ActiveRecord::Base.establish_connection(@tenant.database_config)
ActiveRecord::MigrationContext.new(
ActiveRecord::Migrator.migrations_paths,
ActiveRecord::SchemaMigration
).migrate
else
# Schema already exists with migrations
end
end
def seed_default_data
TenantDefaults.create!(
tenant: @tenant,
settings: default_settings,
features: default_features
)
end
end
Because provisioning can take a few seconds, I always run it in a background job:
class ProvisionTenantJob < ApplicationJob
queue_as :provisioning
def perform(tenant_id)
tenant = Tenant.find(tenant_id)
TenantProvisioner.new(tenant).provision
end
end
This job runs after the tenant record is created in the main database. The user gets a loading screen while the setup finishes. I also send them a welcome email once the job completes.
Each of these seven patterns has helped me solve real business problems. Database‑level isolation is expensive but gives you the strongest guarantees. Schema‑per‑tenant balances isolation and cost. Row‑level tenancy is the simplest to implement and maintain. Table‑prefix tenancy is a workaround for databases that lack schemas. Application‑level scoping is great for pure SaaS apps. Hybrid approaches let you tailor isolation to the data’s sensitivity. And provisioning automates the most error‑prone part of onboarding.
Start with row‑level tenancy unless you have a clear reason not to. You can always migrate to a more isolated pattern later if regulation or data size demands it. Build your middleware and CurrentAttributes early. Write tests that assert tenants cannot see each other’s data. And never, ever forget to scope a query – a single bare User.all can ruin your weekend.