ruby

7 Multi-Tenant Rails Patterns That Keep Client Data Secure in Production

Discover 7 proven Rails multi-tenancy patterns—from database-level isolation to row-level scoping—with real production code. Find the right approach for your app.

7 Multi-Tenant Rails Patterns That Keep Client Data Secure in Production

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.

Keywords: multi-tenant Rails application, Rails multi-tenancy, Rails SaaS architecture, tenant isolation in Rails, PostgreSQL multi-tenancy, row-level tenancy Rails, schema-per-tenant Rails, database-per-tenant Rails, Rails tenant middleware, CurrentAttributes Rails, Rails SaaS patterns, multi-tenant database design, tenant data isolation, Rails connection switching, PostgreSQL schemas Rails, ActiveRecord multi-tenancy, Rails row-level security, SaaS application architecture, tenant provisioning Rails, multi-tenant Ruby on Rails, Rails default scope tenant, Rails background job provisioning, tenant scoping Rails, ActiveRecord connection pool multi-tenant, Rails subdomain multi-tenancy, hybrid multi-tenancy Rails, Rails tenant middleware tutorial, multi-tenant data separation, Rails SaaS boilerplate, Rails ApplicationRecord tenancy, PCI compliance Rails, tenant onboarding automation, Rails schema migration multi-tenant, cross-tenant data leak prevention, Rails organization scoping, multi-tenant SaaS Ruby, Rails connection adapter custom, PostgreSQL search_path Rails, Rails CurrentAttributes tutorial, multi-tenant architecture patterns, Rails tenant identification, SaaS multi-tenant database patterns, Rails table prefix tenancy, ActiveRecord schema switching, tenant-aware Rails models, Rails provisioning background job, multi-tenant Rails tutorial, Rails data isolation patterns, secure multi-tenancy Rails, Rails SaaS database strategy



Similar Posts
Blog Image
7 Essential Ruby on Rails Testing Gems Every Developer Should Master in 2024

Discover 7 essential Ruby on Rails testing gems including RSpec, FactoryBot & Capybara. Complete with code examples to build reliable applications. Start testing like a pro today.

Blog Image
7 Advanced Ruby Object Model Patterns for Better Rails Applications

Master Ruby object patterns for maintainable Rails apps. Learn composition, modules, delegation & dynamic methods to build scalable code. Expert tips included.

Blog Image
Rust's Lifetime Magic: Write Cleaner Code Without the Hassle

Rust's advanced lifetime elision rules simplify code by allowing the compiler to infer lifetimes. This feature makes APIs more intuitive and less cluttered. It handles complex scenarios like multiple input lifetimes, struct lifetime parameters, and output lifetimes. While powerful, these rules aren't a cure-all, and explicit annotations are sometimes necessary. Mastering these concepts enhances code safety and expressiveness.

Blog Image
Building Event-Sourced Ruby Systems: Complete Guide with PostgreSQL and Command Patterns

Discover practical Ruby techniques for building event-sourced systems with audit trails and temporal analysis. Learn event stores, concurrency, and projections. Perfect for financial apps.

Blog Image
How to Build a Scalable Notification System in Ruby on Rails: A Complete Guide

Learn how to build a robust notification system in Ruby on Rails. Covers real-time updates, email delivery, push notifications, rate limiting, and analytics tracking. Includes practical code examples. #RubyOnRails #WebDev

Blog Image
Why Should You Use the Geocoder Gem to Power Up Your Rails App?

Making Location-based Magic with the Geocoder Gem in Ruby on Rails