PostgresMultitenant
Schema-based multi-tenancy for Rails applications backed by PostgreSQL schemas.
PostgresMultitenant provides:
- request middleware that switches PostgreSQL
search_pathby tenant subdomain - schema lifecycle helpers for creating, dropping, switching, and listing tenants
- tenant schema sync from
db/tenant_schema.rb, withdb/schema.rbfallback - Rails rake tasks for tenant lifecycle operations
- a Rails install generator
Requirements
- Ruby 3.1+
- Rails 6.1+
- PostgreSQL with
schema_search_pathsupport
The gem is compatible with Rails 8.1.x, including Rails 8.1.3. Rails 8.1 itself requires Ruby 3.2+, so applications on Ruby 3.1 should stay on an older compatible Rails release.
Installation
Add the gem to your Rails app:
gem "postgres_multitenant"
Then run:
bundle install
bin/rails generate postgres_multitenant:install
The generator creates:
config/initializers/postgres_multitenant.rbdb/tenant_schema.rb
Generator options:
bin/rails generate postgres_multitenant:install --tenant-class-name=Account --tenant-foreign-key=account_id
Configuration
Edit config/initializers/postgres_multitenant.rb after running the generator:
PostgresMultitenant.configure do |config|
# Public-schema model that identifies tenants by subdomain.
config.tenant_class_name = "Organization"
# Required: return tenant-scoped table names without schema prefixes.
config.tenant_table_resolver = -> { %w[clients projects bookings] }
# Optional: scope tenant records considered for routing and tenant migration.
config.tenant_scope_proc = ->(klass) { klass.where(active: true) }
# Optional: custom subdomain lookup.
config.tenant_finder = ->(subdomain) { Organization.find_by(subdomain: subdomain) }
# Optional: custom schema naming.
config.tenant_schema_name_proc = ->(tenant) { tenant.subdomain.tr("-", "_") }
# Tenant foreign key expected on tenant-scoped tables during development checks.
config.tenant_foreign_key = "organization_id"
# Optional: override request-to-subdomain extraction.
# config.subdomain_extractor = ->(request) { request.subdomain.presence }
# Strategy when tenant is not found:
# :raise -> raise PostgresMultitenant::TenantNotFoundError
# :public -> continue in config.default_schema
# :custom -> call config.tenant_not_found_handler
config.tenant_not_found_strategy = :raise
# Required when tenant_not_found_strategy is :custom.
# config.tenant_not_found_handler = ->(_request, _subdomain) {
# [404, {"Content-Type" => "text/plain"}, ["Tenant not found"]]
# }
end
Data Model Contract
Your tenant class, for example Organization, should:
- live in the public schema
- expose a
subdomainattribute, unlesstenant_finderis configured - expose a schema name through
schema_name, or usetenant_schema_name_proc
Tenant-scoped tables should include the configured tenant foreign key, such as organization_id, unless the table is listed in config.system_tables.
Tenant Schema
Prefer defining tenant-only tables in db/tenant_schema.rb:
ActiveRecord::Schema.define do
create_table "projects", force: :cascade do |t|
t.bigint "organization_id", null: false
t.string "name", null: false
t.
end
end
If db/tenant_schema.rb is absent, PostgresMultitenant loads db/schema.rb and prunes tables that are not returned by:
config.tenant_table_resolver.callconfig.system_tables
Usage
PostgresMultitenant.switch("acme") { Project.count }
PostgresMultitenant.create("acme")
PostgresMultitenant.drop("acme")
PostgresMultitenant.current #=> "public" or tenant schema
PostgresMultitenant.tenants #=> ["acme", "beacon"]
PostgresMultitenant.reset!
Schema names must be lowercase PostgreSQL-safe identifiers matching:
\A[a-z][a-z0-9_]*\z
Reserved schemas such as public, information_schema, and pg_* cannot be used as tenant schemas.
Rake Tasks
bin/rails tenants:create[acme]
bin/rails tenants:drop[acme]
bin/rails tenants:list
bin/rails tenants:migrate
tenants:migrate keeps tenant schemas in sync by loading db/tenant_schema.rb or db/schema.rb; it does not run Rails migrations inside tenant schemas.
Development
Run the test suite:
rake test
Build the gem:
gem build postgres_multitenant.gemspec
Security Notes
- Keep
tenant_not_found_strategy = :raiseunless public-schema fallback is intentional. - Keep public tables and tenant tables separated by design.
- Do not include tenant-owned data in public-schema models.
- Review
config.tenant_table_resolverwhenever adding tenant-scoped tables.