ActiveRecord::Tenanted Needs Geo-Aware Lazy Migrations
ActiveRecord::Tenanted, announced at RailsWorld 2025, is an exciting step forward for multi-tenant Rails applications. It brings database-per-tenant architecture into the mainstream with first-class Rails integration.
But there's a problem: its migration strategy won't scale to geographically distributed deployments.
With DHH also announcing Kamal Geo Proxy at the same conference, it's clear that geo-distributed Rails apps are becoming a first-class deployment pattern. ActiveRecord::Tenanted needs to evolve to support this architecture.
I've been running Showcase—a geographically distributed multi-tenant application serving 70+ sites across 8 countries—in production for over 3 years. The migration patterns I've developed are battle-tested and ready to be adopted by ActiveRecord::Tenanted.
This post advocates for ActiveRecord::Tenanted to become geo-aware and support lazy migrations.
The Problem with ActiveRecord::Tenanted's Current Approach
ActiveRecord::Tenanted (v0.4.1) uses eager synchronous migrations. Every database must be fully migrated before it can be accessed:
- New tenants: Block during
create_tenant
until migrations complete - Existing tenants: Must be pre-migrated via
rake db:migrate:tenant:all
- Connection pools: Check for
PendingMigrationError
on every access
This works fine for a single Rails application. But it breaks down in geo-distributed architectures where:
- Hundreds or even thousands of databases need migrating across multiple machines
- Different regions host different subsets of tenants
- Migrations block deployments: Kamal provides zero-downtime for Rails, but waits until all migrations complete before routing traffic to new containers and decommissioning old ones
- Coordination across machines becomes complex
How Showcase Solves This
Showcase uses lazy migrations with background preparation and geographic awareness. Here's how it works:
1. Geographic Awareness
Each machine knows which databases it's responsible for via tmp/tenants.list
. This file is generated based on region/machine configuration, ensuring:
iad
(Virginia) databases are migrated byiad
machinesams
(Amsterdam) databases are migrated byams
machinessyd
(Sydney) databases are migrated bysyd
machines- Read-only databases (hosted in other regions) are never migrated locally
- No redundant migration work across regions
2. Background Migration During Startup
On deployment, the server starts in maintenance mode and runs bin/prerender
, which:
- Reads the list of assigned databases from
tmp/tenants.list
- Runs
bin/prepare.rb
to migrate all databases in parallel - Uses a fast-path check: queries
SELECT version FROM schema_migrations
to skip already-migrated databases - Includes built-in throttling (
sleep 1
) to prevent resource exhaustion
Once background migrations start (but don't have to finish), the server switches out of maintenance mode and begins serving traffic.
3. On-Demand Safety Net
If a request arrives for a database that hasn't been migrated yet, config.ru
runs the migration on-demand before starting the Rails instance. This ensures correctness even when background migration hasn't completed.
4. File-Based Locking
.lock
files prevent concurrent migrations of the same database across multiple processes on the same machine.
Why This Works
Performance: The fast-path check (SELECT version FROM schema_migrations
) is ~1000× faster than ActiveRecord's pending_migrations
check, taking only 5-10ms per database.
Zero-downtime: Traffic starts flowing immediately after deployment. Background migration happens concurrently.
Geographic efficiency: Each region only migrates its own databases—no wasted work.
Resilience: The on-demand safety net in config.ru
ensures correctness even if background migration hasn't finished.
What ActiveRecord::Tenanted Needs
For ActiveRecord::Tenanted to work with Kamal Geo Proxy and geographically distributed deployments, it needs:
1. Tenant Filtering API
# Configure which tenants this machine is responsible for
# The exact API will depend on Kamal Geo Proxy's design
config.active_record.tenanted.tenant_filter = ->(name) do
# Application-specific logic to determine which tenants
# this machine is responsible for migrating
# All other tenants are treated as read-only
end
This allows each machine to know which databases it's responsible for migrating. Any tenant not matching the filter is treated as read-only (hosted elsewhere). The implementation will depend on how Kamal Geo Proxy handles routing and region assignment.
2. Background Migration Task
# Non-blocking migration that returns immediately
rake db:migrate:tenant:background
# Or with filtering
rake db:migrate:tenant:background REGION=iad
The task should:
- Start migrations in a background thread/process
- Allow the server to start serving traffic immediately
- Include throttling to prevent resource exhaustion
3. Lazy Migration Mode
# In config/application.rb
config.active_record.tenanted.migration_mode = :lazy
# This changes connection pool behavior:
# - Don't raise PendingMigrationError
# - Run migration on-demand when accessing unmigrated database (only for filtered tenants)
# - Skip migrations entirely for tenants not matching the filter
# - Use fast-path check (query schema_migrations directly)
4. Fast-Path Migration Check
Replace the expensive pending_migrations
check with a direct query:
def migrations_pending?(tenant_name)
applied = execute("SELECT version FROM schema_migrations").flatten
(migration_versions - applied).any?
end
This is ~1000× faster and makes the lazy check negligible.
5. Built-in Throttling
# In config/application.rb
config.active_record.tenanted.migration_throttle = 1.second # Sleep between migrations
Proposed API
Here's what it could look like:
# config/application.rb
config.active_record.tenanted.migration_mode = :lazy
config.active_record.tenanted.migration_throttle = 1.second
# Configure which tenants this machine is responsible for
# The exact logic will depend on your infrastructure and Kamal Geo Proxy's design
config.active_record.tenanted.tenant_filter = ->(name) do
# Example: filter by region prefix, machine ID, or other criteria
# This is application-specific
# All other tenants are treated as read-only
end
Why This Matters
ActiveRecord::Tenanted is the future of multi-tenancy in Rails. But to fulfill that promise, it needs to work with Kamal Geo Proxy and distributed deployments.
The patterns in Showcase have been battle-tested for 3+ years across:
- 70+ sites in 8 countries
- Non-blocking deployments migrating hundreds or even thousands of databases without waiting
- Geographic distribution with region-aware migrations
- Production reliability under real-world load
These aren't experimental ideas—they're proven patterns ready to be adopted.
A Path Forward
I'm not asking ActiveRecord::Tenanted to abandon its current approach. Eager synchronous migrations are perfect for single-application deployments.
I'm asking for options. Let users choose:
- Eager mode (current): Simple, synchronous, strong guarantees—perfect for single deployments
- Lazy mode (proposed): Background prep, geo-aware, non-blocking—essential for distributed deployments
Both modes can coexist. The lazy mode could even be opt-in via configuration.
Let's Make It Happen
If you're working on ActiveRecord::Tenanted or thinking about distributed multi-tenancy, I'd love to collaborate. The code is in Showcase—it's open source and ready to be adapted.
Discussion of this proposal is taking place at activerecord-tenanted#213.
My goal is to eventually migrate Showcase to use ActiveRecord::Tenanted. But that requires these features. I suspect many others have similar needs.
Rails deserves a multi-tenancy solution that works everywhere—from a single DigitalOcean Droplet to a globally distributed Kamal deployment.
Let's build it together.
Resources:
- ActiveRecord::Tenanted - Current implementation
- Showcase - Battle-tested geo-distributed implementation
- Kamal Proxy - Zero-downtime deployments for Rails
- RailsWorld 2025 Talk - Original ActiveRecord::Tenanted announcement