SkillAgentSearch skills...

Apartment

Database multi-tenancy for Rack (and Rails) applications

Install / Use

/learn @rails-on-services/Apartment
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

Apartment

Gem Version CI codecov

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
end

When to Use Apartment

Apartment 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.

About ros-apartment

This gem is a maintained fork of the original Apartment gem. Maintained by CampusESP since 2024. Same require 'apartment'; v4 introduces a pool-per-tenant architecture that replaces the thread-local switching of v3. Tenant context is fiber-safe via CurrentAttributes, and connection pools are managed per tenant rather than swapping search paths on a shared connection. See the upgrade guide for migration steps from v3.

Installation

Requirements

  • Ruby 3.3+
  • Rails 7.2+
  • PostgreSQL 14+, MySQL 8.4+, or SQLite3

Setup

# Gemfile
gem 'ros-apartment', require: 'apartment'
bundle install
bundle exec rails generate apartment:install

Quick Start

The generated initializer at config/initializers/apartment.rb configures Apartment:

Apartment.configure do |config|
  config.tenant_strategy = :schema          # :schema (PostgreSQL) or :database_name (MySQL/SQLite)
  config.tenants_provider = -> { Customer.pluck(:subdomain) }
  config.default_tenant = 'public'          # auto-defaults for :schema; required for :database_name
end

Tenant context is block-scoped. Always use Apartment::Tenant.switch with a block in application code; it guarantees cleanup on exceptions.

Apartment::Tenant.create('acme')

Apartment::Tenant.switch('acme') do
  User.create!(name: 'Alice')  # in the 'acme' schema
end

Apartment::Tenant.drop('acme')

switch! exists for console/REPL use but is discouraged in application code.

Global models that live outside tenant schemas use pin_tenant:

class Company < ApplicationRecord
  include Apartment::Model
  pin_tenant  # always queries the default (public) schema
end

Configuration Reference

All options are set in config/initializers/apartment.rb inside an Apartment.configure block.

Required Options

tenant_strategy: the isolation method. :schema for PostgreSQL schema-per-tenant, :database_name for MySQL/SQLite database-per-tenant.

tenants_provider: a callable that returns tenant names. Called at migration time and by rake tasks. Example: -> { Customer.pluck(:subdomain) }.

Pool Settings

tenant_pool_size: connections per tenant pool (default: 5).

pool_idle_timeout: seconds before an idle tenant pool is eligible for reaping (default: 300).

max_total_connections: hard cap across all tenant pools; nil for unlimited (default: nil).

Elevator (Request Tenant Detection)

config.elevator = :subdomain
config.elevator_options = {}

The Railtie auto-inserts elevator middleware after ActionDispatch::Callbacks (just before cookies/sessions in full mode; works in API mode too).

See the Elevators section for available options.

Migrations

parallel_migration_threads: number of threads for parallel tenant migration; 0 for sequential (default: 0).

schema_load_strategy: how to initialize new tenant schemas on create. nil (no schema loading), :schema_rb, or :sql (default: nil).

seed_after_create: run seeds after tenant creation (default: false).

seed_data_file: path to a custom seeds file; uses db/seeds.rb when nil (default: nil).

schema_file: path to a custom schema file for tenant creation (default: nil).

check_pending_migrations: raise PendingMigrationError in local environments when a tenant has unapplied migrations (default: true).

Advanced

schema_cache_per_tenant: load per-tenant schema cache files when establishing tenant pools (default: false).

active_record_log: tag Rails log output with the current tenant using ActiveSupport::TaggedLogging. Log lines inside a switch block are tagged with tenant=name; nested switches stack tags ([tenant=acme] [tenant=widgets]). Requires Rails.logger to respond to tagged (default: false).

sql_query_tags: add a tenant tag to ActiveRecord::QueryLogs so SQL queries include a /* tenant='name' */ comment. Visible in slow query logs, pg_stat_activity, and database monitoring tools (default: false).

shard_key_prefix: prefix for ActiveRecord shard keys used in tenant pool registration (default: 'apartment'). Must match /[a-z_][a-z0-9_]*/.

Tenant Naming

environmentify_strategy: how to namespace tenant names per Rails environment. nil (no prefix), :prepend, :append, or a callable (default: nil).

RBAC

migration_role: a Symbol naming the database role used for migrations (default: nil, uses the connection's default role).

app_role: a String or callable returning the restricted role for application queries (default: nil).

PostgreSQL

Apartment.configure do |config|
  config.configure_postgres do |pg|
    pg.persistent_schemas = ['shared_extensions']
  end
end

PostgreSQL extensions (hstore, uuid-ossp, etc.) should be installed in a persistent schema so they're accessible from all tenant schemas:

# 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"

Additional PostgreSQL options (set inside the configure_postgres block):

include_schemas_in_dump: non-public schemas to include in schema dumps, e.g., %w[ext shared] (default: []).

MySQL

Apartment.configure do |config|
  config.configure_mysql do |my|
    # MySQL-specific options
  end
end

Elevators

Elevators are Rack middleware that detect the tenant from the incoming request and call Apartment::Tenant.switch for the duration of that request.

Available elevators:

  • Subdomain: acme.example.com -> 'acme'
  • Domain: acme.com -> 'acme'
  • Host: full hostname matching
  • HostHash: { 'acme.com' => 'acme_tenant' }
  • FirstSubdomain: first subdomain in a multi-level chain
  • Header: tenant name from an HTTP header (new in v4)

Configuration via config.elevator:

Apartment.configure do |config|
  config.elevator = :subdomain
end

The Railtie inserts the elevator after ActionDispatch::Callbacks automatically. In the full middleware stack this places it just before cookies, sessions, and authentication. In API mode (where cookies/sessions are absent), Callbacks is still present so the elevator works without changes.

If you need different positioning, skip config.elevator and insert manually:

# config/application.rb
config.middleware.insert_before 'Warden::Manager', Apartment::Elevators::Subdomain

Custom Elevator

class MyElevator < Apartment::Elevators::Generic
  def parse_tenant_name(request)
    request.host.split('.').first
  end
end

Then pass the class directly:

config.elevator = MyElevator

Pinned Models (Global Tables)

Models that belong to all tenants (users, companies, plans) are pinned to the default schema:

class User < ApplicationRecord
  include Apartment::Model
  pin_tenant
end

Why pin_tenant:

  • Declarative: the model declares its own tenancy, not a distant config list
  • Zeitwerk-safe: no string-to-class resolution at boot time
  • Composable: works with connected_to(role: :reading) for read replicas

U

Related Skills

View on GitHub
GitHub Stars480
CategoryData
Updated26m ago
Forks181

Languages

Ruby

Security Score

80/100

Audited on Apr 10, 2026

No findings