Schema-per-tenant Rails, and the bug that only shows up in a background job
5 min read
Most multi-tenancy tutorials show you the same thing: add a tenant_id column, slap a
default_scope on your models, done. That works right up until a background job runs with no
tenant set and quietly reads — or writes — the wrong company’s data. On a field-service SaaS I ran
the backend for, we sidestepped the whole tenant_id-on-every-table approach and gave each tenant
its own Postgres schema. It made the day-to-day code blissfully tenant-unaware. It also moved
all the danger to one place: the boundary where a job leaves the web request.
This is about that boundary.
Each tenant is a schema, and almost nothing knows
We used the apartment gem for schema-based isolation. The important config is short: only a
handful of tables live in the shared public schema — the ones that have to be global, like the
tenant registry and the users-to-tenants mapping — and everything else lives per-tenant.
# config/initializers/apartment.rb
config.excluded_models = %w{ Tenant TenantUser User FeatureFlag FeatureFlagTenant FeatureFlagAudit }
Rails.application.config.middleware.use Apartment::Elevators::Tenant
The payoff of schema isolation is that your domain models carry no tenant column and no tenant
scope. A query for invoices is just Invoice.where(...) — it can’t accidentally return another
tenant’s rows because the other tenant’s rows aren’t in the schema the connection is pointed at.
That’s a strong guarantee, and it’s structural, not something a developer has to remember on every
query.
Switching schema per request is a Rack “elevator” keyed on the subdomain:
def parse_tenant_name(request)
request_subdomain = subdomain(request.host)
tenant = excluded_subdomains.include?(request_subdomain) ? nil : request_subdomain
if tenant
::Tenant.current_tenant = ::Tenant.where("LOWER(subdomain) = LOWER(?)", tenant).first
::Tenant.current_tenant.name
else
::Tenant.current_tenant = nil
end
end
Switching isn’t only SET search_path. The tenant also decides the timezone and the locale, so the
switch has to re-establish those too — otherwise a job formats a date or a currency in the wrong
region:
def self.switch_to_block(name, &block)
Apartment::Tenant.switch(name) do
Tenant.current_tenant = Tenant.where(name: name).first
Time.zone = Preferences.timezone
I18n.locale = resolved_locale # validated against I18n.available_locales, with a fallback
yield block
end
end
Tenant lifecycle falls out of the same idea: creating a tenant creates a schema
(after_create :create_schema → Apartment::Tenant.create), destroying one drops it. Onboarding a
customer is a DDL operation, which is a genuinely different mental model from inserting a row — with
trade-offs I’ll come back to.
Where it breaks: the queue has no subdomain
Here’s the whole problem in one sentence: the Rack elevator sets the tenant from the request’s
subdomain, and a background job has no request. Enqueue an invoice-sync job while serving
acme.example.com, let it run on a worker a second later, and there is no subdomain to read. The
connection is pointed at public (or worse, whatever schema the worker touched last), and your
“just Invoice.where(...)” guarantee evaporates. Depending on timing you get no rows or, if a
previous job left a schema selected on that thread, someone else’s rows.
We ran jobs on Amazon SQS via Shoryuken, and the fix is a pair of middlewares — one on the way out, one on the way in. On enqueue, stamp the current tenant onto the SQS message as an attribute:
class ShoryukenClientApartment
def call(body)
if body[:message_attributes]
body[:message_attributes][:tenant] = { string_value: Apartment::Tenant.current, data_type: "String" }
end
yield
end
end
On the worker, read that attribute back and run the whole job inside the tenant’s schema:
class ShoryukenServerApartment
def call(worker_instance, queue, sqs_msg, body)
tenant_name = sqs_msg.message_attributes["tenant"]
Tenant.switch_to_block(tenant_name ? tenant_name.string_value : "public") do
yield
end
rescue => e
Rollbar.error e
raise e
end
end
That’s the entire fix, and it’s worth staring at because it’s the pattern for carrying any
request-scoped context across a queue — tenant, locale, a request id, the acting user. The queue is
a serialization boundary; anything implicit on the sender that the job needs has to be made
explicit on the message. The tenant was implicit (it lived in Apartment::Tenant.current, derived
from the subdomain), so it had to be written onto the envelope and re-applied on the other side.
Wrapping the whole yield in switch_to_block — not just part of it — matters: every query, every
nested job the worker enqueues, every callback, runs inside the right schema, and it’s restored
when the block exits so the next job on that worker starts clean.
Note the "public" fallback. If a message somehow arrives without a tenant, we run against the
shared schema, which has none of the domain tables — so the job fails loudly instead of silently
operating on the wrong data. A job that can’t find its tenant should crash, not guess.
Was schema-per-tenant worth it?
Honestly, it depends, and I wouldn’t reach for it by default. What you buy is real: isolation you
can’t forget to apply, trivially per-tenant everything (backups, exports, “restore just this
customer”), and no WHERE tenant_id = ? noise in a thousand queries. What you pay is also real:
migrations run once per schema, so a thousand tenants is a thousand ALTER TABLEs (and a slow,
carefully-batched deploy); connection pooling and search_path switching add operational edges;
and onboarding is DDL, which is heavier than an INSERT. Row-level tenancy inverts every one of
those trade-offs.
But the lesson that transfers regardless of which model you pick is the queue one. Whatever holds
your tenant context in a request — a thread-local, a CurrentAttributes, a connection’s
search_path — is invisible to a background job, and “invisible” is exactly how a cross-tenant
data leak gets written. Put the tenant on the message. Re-apply it around the entire job. Make the
missing-tenant case fail instead of default.
I owned this multi-tenant backend for years — schema isolation, the job plumbing, the tenant lifecycle. If you’re weighing schema-per-tenant vs row-level and want to talk through the trade-offs for your app, my inbox is open.