Database-level multitenancy for Rails and ActiveRecord
Apartment isolates tenant data at the database level — using PostgreSQL schemas or separate databases — so that tenant data separation is enforced by the database engine, not application code.
Apartment::Tenant.switch('acme') do
User.all # only returns users in the 'acme' schema/database
endApartment uses schema-per-tenant (PostgreSQL) or database-per-tenant (MySQL/SQLite) isolation. This is one of several approaches to multitenancy in Rails. Choose the right one for your situation:
| Approach | Isolation | Best for | Gem |
|---|---|---|---|
Row-level (shared tables, WHERE tenant_id = ?) |
Application-enforced | Many tenants, greenfield apps, cross-tenant reporting | acts_as_tenant |
| Schema-level (PostgreSQL schemas) | Database-enforced | Fewer high-value tenants, regulatory requirements, retrofitting existing apps | ros-apartment |
| Database-level (separate databases) | Full isolation | Strictest isolation, per-tenant performance tuning | ros-apartment |
Use Apartment when you need hard data isolation between tenants — where a missed WHERE clause can't accidentally leak data across tenants. This is common in regulated industries, B2B SaaS with contractual isolation requirements, or when retrofitting an existing single-tenant app.
Consider row-level tenancy instead if you have many tenants (hundreds+), need cross-tenant queries, or are starting a greenfield project. Row-level is simpler, uses fewer database resources, and scales more linearly. See the Arkency comparison for a thorough analysis.
This gem is a maintained fork of the original Apartment gem. Maintained by CampusESP since 2024. Drop-in replacement — same require 'apartment', same API.
- Ruby 3.3+
- Rails 7.2+
- PostgreSQL 14+, MySQL 8.4+, or SQLite3
# Gemfile
gem 'ros-apartment', require: 'apartment'bundle install
bundle exec rails generate apartment:installThis creates config/initializers/apartment.rb. Configure it:
Apartment.configure do |config|
config.excluded_models = ['User', 'Company'] # shared across all tenants
config.tenant_names = -> { Customer.pluck(:subdomain) }
endApartment::Tenant.create('acme') # creates schema/database + runs migrations
Apartment::Tenant.drop('acme') # permanently deletes tenant dataAlways use the block form — it guarantees cleanup even on exceptions:
Apartment::Tenant.switch('acme') do
# all ActiveRecord queries scoped to 'acme'
User.create!(name: 'Alice')
end
# automatically restored to previous tenantswitch! exists for console/REPL use but is discouraged in application code.
Elevators are Rack middleware that detect the tenant from the request and switch automatically:
# config/application.rb — pick one:
config.middleware.use Apartment::Elevators::Subdomain # acme.example.com → 'acme'
config.middleware.use Apartment::Elevators::Domain # acme.com → 'acme'
config.middleware.use Apartment::Elevators::Host # full hostname matching
config.middleware.use Apartment::Elevators::HostHash, { 'acme.com' => 'acme_tenant' }
config.middleware.use Apartment::Elevators::FirstSubdomain # first subdomain in chainImportant: Position the elevator middleware before authentication middleware (e.g., Warden/Devise) to ensure tenant context is established before auth runs:
config.middleware.insert_before Warden::Manager, Apartment::Elevators::Subdomain# app/middleware/my_elevator.rb
class MyElevator < Apartment::Elevators::Generic
def parse_tenant_name(request)
# return tenant name based on request
request.host.split('.').first
end
endModels that exist globally (not per-tenant):
config.excluded_models = ['User', 'Company']These models always query the default (public) schema. Use has_many :through for associations — has_and_belongs_to_many is not supported with excluded models.
Apartment::Elevators::Subdomain.excluded_subdomains = ['www', 'admin', 'public']All options are set in config/initializers/apartment.rb:
Apartment.configure do |config|
# Required: how to discover tenant names (must be a callable)
config.tenant_names = -> { Customer.pluck(:subdomain) }
# Excluded models — shared across all tenants
config.excluded_models = ['User', 'Company']
# Default schema/database (default: 'public' for PostgreSQL)
config.default_tenant = 'public'
# Prepend Rails environment to tenant names (useful for dev/test)
config.prepend_environment = !Rails.env.production?
# Seed new tenants after creation
config.seed_after_create = true
# Enable ActiveRecord query logging with tenant context
config.active_record_log = true
endApartment.configure do |config|
# Schemas that remain in search_path for all tenants
# (useful for shared extensions like hstore, uuid-ossp)
config.persistent_schemas = ['shared_extensions']
# Use raw SQL dumps instead of schema.rb for tenant creation
# (needed for materialized views, custom types, etc.)
config.use_sql = true
endPostgreSQL extensions (hstore, uuid-ossp, etc.) should be installed in a persistent schema:
# lib/tasks/db_enhancements.rake
namespace :db do
task extensions: :environment do
ActiveRecord::Base.connection.execute('CREATE SCHEMA IF NOT EXISTS shared_extensions;')
ActiveRecord::Base.connection.execute('CREATE EXTENSION IF NOT EXISTS HSTORE SCHEMA shared_extensions;')
ActiveRecord::Base.connection.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA shared_extensions;')
end
end
Rake::Task['db:create'].enhance { Rake::Task['db:extensions'].invoke }
Rake::Task['db:test:purge'].enhance { Rake::Task['db:extensions'].invoke }Ensure your database.yml includes the persistent schema:
schema_search_path: "public,shared_extensions"Tenant migrations run automatically with rake db:migrate. Apartment iterates all tenants from config.tenant_names.
# Disable automatic tenant migration if needed
Apartment.db_migrate_tenants = false # in Rakefile, before load_tasksFor applications with many schemas:
config.parallel_migration_threads = 4 # 0 = sequential (default)
config.parallel_strategy = :auto # :auto, :threads, or :processesPlatform notes: :auto uses threads on macOS (libpq fork issues) and processes on Linux. Parallel migrations disable PostgreSQL advisory locks — ensure your migrations are safe to run concurrently.
Store tenants on different database servers:
config.with_multi_server_setup = true
config.tenant_names = -> {
Tenant.all.each_with_object({}) do |t, hash|
hash[t.name] = { adapter: 'postgresql', host: t.db_host, database: 'postgres' }
end
}Hook into tenant lifecycle events:
require 'apartment/adapters/abstract_adapter'
Apartment::Adapters::AbstractAdapter.set_callback :create, :after do |adapter|
# runs after a new tenant is created
end
Apartment::Adapters::AbstractAdapter.set_callback :switch, :before do |adapter|
# runs before switching tenants
endFor Sidekiq and ActiveJob tenant propagation:
Apartment adds console helpers:
tenant_list— list available tenantsst('tenant_name')— switch to a tenant
For a tenant-aware prompt, add require 'apartment/custom_console' to application.rb (requires pry-rails).
Skip initial DB connection on boot:
APARTMENT_DISABLE_INIT=true rails runner 'puts 1'Skip tenant presence check (saves one query per switch on PostgreSQL):
config.tenant_presence_check = false- Check existing issues and discussions
- Fork and create a feature branch
- Write tests — we don't merge without them
- Run
bundle exec rspec spec/unit/andbundle exec rubocop - Use Appraisal to test across Rails versions:
bundle exec appraisal rspec spec/unit/ - Submit PR to the
developmentbranch
See CONTRIBUTING.md for full guidelines.