Building Scalable Multi-Tenant Systems in Ruby
Multi-tenancy transforms how we deliver software. It allows one application instance to serve multiple customers securely. I’ve built several SaaS platforms using Ruby on Rails, and these seven techniques proved essential for balancing efficiency with security.
Identifying tenants forms the foundation. I prefer extracting tenant context early in the request cycle. This middleware approach works reliably:
class TenantResolver
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
tenant_identifier = request.subdomain.presence || request.headers['X-Tenant-ID']
return invalid_tenant_response unless tenant_identifier
ActiveRecord::Base.connected_to(tenant: tenant_identifier) do
@app.call(env)
end
rescue ActiveRecord::RecordNotFound
[404, { 'Content-Type' => 'application/json' }, ['Tenant not found']]
end
private
def invalid_tenant_response
[401, { 'Content-Type' => 'application/json' }, ['Valid tenant identifier required']]
end
end
# config/application.rb
config.middleware.use TenantResolver
This resolves tenants from subdomains or headers. The connected_to
block from Rails 6+ handles database switching cleanly. For early-stage apps, I often start with schema-per-tenant isolation using PostgreSQL:
# db/migrate/20230501120000_create_tenant_schemas.rb
class CreateTenantSchemas < ActiveRecord::Migration[7.0]
def change
Tenant.find_each do |tenant|
create_schema tenant.schema_name
end
end
end
# app/models/tenant.rb
class Tenant < ApplicationRecord
after_create :prepare_schema
def switch
ActiveRecord::Base.connection.schema_search_path = schema_name
yield
ensure
ActiveRecord::Base.connection.schema_search_path = 'public'
end
private
def prepare_schema
Apartment::Tenant.create(schema_name)
end
end
Model scoping prevents data leaks. I automatically attach tenant context to records:
module TenantScoped
extend ActiveSupport::Concern
included do
belongs_to :tenant
default_scope { where(tenant_id: Current.tenant.id) }
validates :tenant, presence: true
end
end
# app/models/document.rb
class Document < ApplicationRecord
include TenantScoped
end
# Usage
Tenant.find('acme').switch do
Document.create!(title: 'Contract') # Automatically scoped to ACME tenant
end
The default scope ensures queries never cross tenant boundaries accidentally. For shared database approaches, I add tenant IDs to all tables and use row-level security:
# PostgreSQL RLS policy
ActiveRecord::Base.connection.execute <<~SQL
CREATE POLICY tenant_isolation_policy ON documents
USING (tenant_id = current_setting('app.current_tenant')::uuid)
SQL
Background jobs require explicit tenant handling. I pass tenant identifiers in job arguments:
class DocumentProcessingJob < ApplicationJob
def perform(document_id, tenant_id)
Tenant.find(tenant_id).switch do
document = Document.find(document_id)
# Processing logic here
end
end
end
# Enqueueing
DocumentProcessingJob.perform_later(@document.id, Current.tenant.id)
For Sidekiq, I use custom middleware:
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
chain.add TenantJobMiddleware
end
end
class TenantJobMiddleware
def call(_worker, job, _queue)
tenant_id = job['tenant_id']
Tenant.find(tenant_id).switch do
yield
end
end
end
Authentication needs tenant-aware constraints. I modify Devise to scope users:
# app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable
belongs_to :tenant
validates :email, uniqueness: { scope: :tenant_id }
end
# SessionsController override
class SessionsController < Devise::SessionsController
def create
tenant = Tenant.find_by(subdomain: request.subdomain)
user = tenant.users.find_by(email: params[:user][:email])
if user&.valid_password?(params[:user][:password])
sign_in user
else
flash[:alert] = 'Invalid credentials'
end
end
end
Caching requires tenant isolation. I namespace cache keys:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
around_action :switch_tenant
def switch_tenant(&block)
Current.tenant = Tenant.find_by(identifier: request.subdomain)
Rails.cache.with_namespace(Current.tenant.cache_key, &block)
end
end
# Fragment caching
<% cache [Current.tenant, @document] do %>
<%= render @document %>
<% end %>
Redis works well for partitioned caching:
# config/initializers/cache_store.rb
Rails.application.configure do
config.cache_store = :redis_cache_store, {
namespace: -> { "tenant_#{Current.tenant.id}" }
}
end
Billing systems track tenant usage. I instrument ActiveRecord callbacks:
# app/models/concerns/tenant_metering.rb
module TenantMetering
extend ActiveSupport::Concern
included do
after_create :track_resource_creation
end
def track_resource_creation
Current.tenant.increment!(:storage_units, storage_size)
end
end
# Monthly billing job
class BillingJob < ApplicationJob
def perform
Tenant.find_each do |tenant|
tenant.switch do
usage = tenant.monthly_usage
BillingService.charge(tenant, usage)
end
end
end
end
Hybrid approaches suit growth stages. I combine strategies:
class Tenant < ApplicationRecord
enum isolation_level: { shared: 0, schema: 1, database: 2 }
def switch(&block)
case isolation_level
when 'shared'
set_current_tenant_id
block.call
when 'schema'
ActiveRecord::Base.connection.schema_search_path = schema_name
block.call
when 'database'
ActiveRecord::Base.connected_to(role: :writing, shard: shard_name, &block)
end
end
end
Performance optimization requires tenant-aware indexing. I add composite indexes:
# Migration
add_index :documents, [:tenant_id, :created_at]
For query optimization:
class Document < ApplicationRecord
scope :recent, -> { order(created_at: :desc).limit(20) }
end
# Uses (tenant_id, created_at) index
Tenant.current.documents.recent
Tenant lifecycle management includes provisioning and deprovisioning:
class TenantProvisioningService
def initialize(tenant)
@tenant = tenant
end
def provision
case @tenant.plan
when 'basic'
setup_shared_tenant
when 'premium'
setup_schema_tenant
end
end
private
def setup_shared_tenant
# Create shared resources
end
def setup_schema_tenant
ActiveRecord::Base.transaction do
create_schema
load_base_data
end
end
end
These techniques evolved through solving real-world scaling challenges. The key is starting simple with shared databases, then introducing stronger isolation as compliance needs grow. I always instrument tenant-specific metrics:
# config/initializers/prometheus.rb
Prometheus::Middleware::Collector.configure do |config|
config.label_builder = ->(env) {
{ tenant: Current.tenant&.identifier || 'unknown' }
}
end
Security remains paramount. I regularly audit tenant boundaries:
class TenantSecurityScanner
def scan
Tenant.find_each do |tenant|
tenant.switch do
verify_data_isolation
end
end
end
private
def verify_data_isolation
other_tenant = Tenant.where.not(id: tenant.id).first
raise BoundaryViolation if User.exists?(tenant_id: other_tenant.id)
end
end
Building multi-tenant systems requires constant trade-offs between efficiency and isolation. With these patterns, I’ve maintained applications serving thousands of tenants on a single Rails instance. The journey involves careful planning but pays dividends in operational simplicity.