7 Ruby on Rails Multi-Tenant Data Isolation Patterns for Secure SaaS Applications
Master 7 proven multi-tenant Ruby on Rails patterns for secure SaaS data isolation. From row-level scoping to database sharding - build scalable apps that protect customer data.
Building SaaS applications requires careful handling of multiple customers’ data. I’ve implemented various patterns to isolate tenant data securely in Ruby on Rails. Here are seven effective approaches I use, with practical examples.
When starting with multi-tenancy, row-level isolation is my first choice. Each table gets a tenant_id column, and all queries automatically scope to the current tenant. I implement this through concerns.
# app/models/concerns/tenant_scoped.rb
module TenantScoped
extend ActiveSupport::Concern
included do
belongs_to :tenant
default_scope { where(tenant_id: Tenant.current_id) }
validates :tenant_id, presence: true
before_validation :set_tenant, on: :create
end
private
def set_tenant
self.tenant_id ||= Tenant.current_id
end
end
# app/models/document.rb
class Document < ApplicationRecord
include TenantScoped
# Business logic here
end
For stronger separation, I use PostgreSQL schema separation. Each tenant gets their own schema while sharing the same database instance. The Apartment gem simplifies this, but I sometimes implement a custom solution.
# lib/tenant_middleware.rb
class TenantMiddleware
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
tenant = Tenant.find_by(subdomain: request.subdomain)
return @app.call(env) unless tenant
Tenant.switch(tenant.schema_name) do
@app.call(env)
end
end
end
# app/models/tenant.rb
class Tenant < ApplicationRecord
def self.switch(schema)
original_schema = ActiveRecord::Base.connection.schema_search_path
ActiveRecord::Base.connection.schema_search_path = schema
yield
ensure
ActiveRecord::Base.connection.schema_search_path = original_schema
end
end
When applications scale, I implement database sharding. Each tenant gets a dedicated database instance. Rails 6+ connection handling works well for this.
# config/database.yml
production:
primary:
database: main
tenant_shard_1:
database: tenant1_prod
migrations_paths: db/tenant_migrate
tenant_shard_2:
database: tenant2_prod
migrations_paths: db/tenant_migrate
# app/models/tenant.rb
class Tenant < ApplicationRecord
def self.using_shard(shard, &block)
ActiveRecord::Base.connected_to(shard: shard, &block)
end
end
# In controller
Tenant.using_shard(current_tenant.shard_key) do
@documents = Document.where(status: :published)
end
Identifying tenants happens through middleware. I resolve tenants from domains, subdomains, or API keys early in the request cycle.
# app/middleware/tenant_resolver.rb
class TenantResolver
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
tenant = resolve_tenant(request)
Tenant.set_current(tenant)
@app.call(env)
ensure
Tenant.clear_current
end
private
def resolve_tenant(request)
case
when request.subdomain.present?
Tenant.find_by!(subdomain: request.subdomain)
when request.headers["X-Tenant-Key"]
Tenant.find_by!(api_key: request.headers["X-Tenant-Key"])
else
raise "Tenant identification failed"
end
end
end
# config/application.rb
config.middleware.use TenantResolver
Background jobs require special handling. I propagate tenant context using ActiveJob hooks.
# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
around_perform :with_tenant_context
private
def with_tenant_context(&block)
tenant_id = arguments.extract_options![:tenant_id]
Tenant.set_current(tenant_id, &block)
end
end
# Usage in controller
DocumentExportJob.perform_later(current_user.id, tenant_id: Tenant.current_id)
Caching needs tenant isolation. I namespace cache keys using tenant identifiers.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
def tenant_cache_key(key)
"tenant/#{Tenant.current_id}/#{key}"
end
end
# In view
<% cache tenant_cache_key(@document) do %>
<%= render @document %>
<% end %>
Security is non-negotiable. I layer controller validations with policy objects.
# app/policies/document_policy.rb
class DocumentPolicy
attr_reader :tenant, :document
def initialize(tenant, document)
@tenant = tenant
@document = document
end
def show?
document.tenant_id == tenant.id
end
end
# app/controllers/documents_controller.rb
def show
@document = Document.find(params[:id])
authorize @document
# Render logic
end
def authorize(record)
policy = DocumentPolicy.new(Tenant.current, record)
raise "Forbidden" unless policy.show?
end
For compliance, I implement audit logs tracking tenant activities.
# app/models/audit_log.rb
class AuditLog < ApplicationRecord
belongs_to :tenant
belongs_to :user
after_create :notify_admins
private
def notify_admins
TenantMailer.audit_alert(tenant, self).deliver_later
end
end
# In controller
AuditLog.create!(
tenant: Tenant.current,
user: current_user,
action: "document.update",
metadata: { document_id: @document.id }
)
Performance considerations include connection pooling. I configure separate pools for shards.
# config/database.yml
tenant_shard_1:
adapter: postgresql
pool: 15
database: tenant1_prod
Migrating from single-tenant to multi-tenant requires careful planning. I run phased rollouts.
# Migration script example
class AddTenantIdToDocuments < ActiveRecord::Migration[7.0]
def change
add_column :documents, :tenant_id, :integer
add_index :documents, :tenant_id
reversible do |dir|
dir.up do
execute "UPDATE documents SET tenant_id = 1" # Default tenant
end
end
end
end
These patterns balance isolation with resource efficiency. Row-based scoping offers simplicity, while sharding provides stronger separation. The right choice depends on compliance needs and scale. I start with schema separation for mid-sized apps, moving to sharding at larger scales. Consistent tenant propagation through jobs and caching prevents data leaks. Security layers enforce boundaries at every access point. With these approaches, I build SaaS platforms that securely scale with customer growth while maintaining data integrity.