About spree-multi-tenant
A developer skill for running Spree 6 as a multi-tenant SaaS, using licensed enterprise gems. It explains tenant isolation, request resolution, setup, and tenant-aware application code.
Key features
- 01Row-level tenant data isolation
- 02Host-based tenant resolution
- 03Store and customer tenancy model
- 04Tenant-scoped jobs and code
- 05Enterprise installation guidance
Use cases
- 01Building a SaaS for independent merchants
- 02Isolating customer data across tenants
- 03Provisioning tenant stores and defaults
- 04Running scheduled work for each tenant
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-multi-tenantAfter installing, type/spree-multi-tenantin Claude Code to confirm it loaded.
Details
From the project READMEMulti-Tenant (SaaS) Spree
Enterprise only. Multi-tenancy ships in the licensed
spree_enterprise+spree_multi_tenantgems (private Keygen gem source). It is not part of open-source Spree. Confirm with Spree which gem release supports your Spree 6 version before planning around it.
Multi-tenant vs multi-store vs marketplace
| Need | Use |
|---|---|
| Many independent merchants, fully isolated data, one deployment (Shopify-style SaaS) | Multi-tenant (this skill) |
| One merchant running several storefronts that share a catalog/customers | Multiple Spree::Stores (+ spree_multi_store for cross-store sharing) — see spree-data-model |
| Many sellers listing products under one shared storefront | Marketplace — see spree-marketplace |
How isolation works
Spree::Tenantis the top-level record. Creating a tenant creates its store and runs Spree's seeds (countries, zones, tax categories…).- Row-level tenancy: every tenanted table has a
tenant_idcolumn. The gem is built onacts_as_tenant. - The gem sets
Spree.base_classtoSpreeMultiTenant::Base, so every Spree model that inherits fromSpree.base_classis tenanted automatically.SpreeMultiTenant::Basealso makesspree_base_uniqueness_scopereturn[:tenant_id], so uniqueness validations (slugs, codes, SKUs) are per tenant. - While a tenant is current, all reads add
WHERE tenant_id = ?and all writes settenant_idautomatically. - The current tenant is resolved per request from the host: tenant subdomain (
store1.example.com) or a custom domain → store →store.tenant_id. Available ascurrent_tenantin controllers andSpreeMultiTenant.current_tenantelsewhere. - One tenant can own several stores.
Spree::Store.defaultreturns the default store of the first tenant — never rely on it in tenant-aware code. - Staff (admin users) are global and can be invited to many tenants. Customers are per tenant — the same email can register at two tenants.
- Isolation applies to the Admin API, Store API and dashboard alike.
Install (summary)
-
Set
KEYGEN_ACCOUNT_IDandKEYGEN_LICENSE_KEYin the server environment (and CI, staging, production build environments). -
spree ejectif you use the Docker CLI flow (gems must be installed into your own image). -
Add to
server/Gemfile:source "https://license:#{ENV['KEYGEN_LICENSE_KEY']}@rubygems.pkg.keygen.sh/#{ENV['KEYGEN_ACCOUNT_ID']}" do gem 'spree_enterprise' gem 'spree_multi_tenant' end -
Docker only: pass the Keygen values to every
bundle installinserver/Dockerfileas BuildKit secrets (RUN --mount=type=secret,id=keygen_license_key,env=KEYGEN_LICENSE_KEY ...) and declare the secrets in both compose files. Never useARG/ENV— that bakes the license into image layers. -
spree bundle install(orbundle install), then:spree generate spree_enterprise:install && spree generate spree_multi_tenant:install # classic: bin/rails g spree_enterprise:install && bin/rails g spree_multi_tenant:installThis copies + runs migrations, wraps the Spree engine mount in
config/routes.rbwith domain constraints (and mounts the tenant app on the app subdomain), includesSpreeMultiTenant::CustomerUserConcernin the customer model, and changes the admin user's parent toSpree::Base.
Post-install configuration
# server/config/initializers/spree.rb
Spree.root_domain = ENV.fetch('SPREE_ROOT_DOMAIN', 'lvh.me')
# server/.env
SPREE_ROOT_DOMAIN=lvh.me # local dev — NOT localhost (browsers reject localhost as a cookie domain)
COOKIE_TLD_LENGTH=2 # labels in the root domain: example.com=2, shop.example.co.uk=3
-
COOKIE_TLD_LENGTHscopes the session cookie to the parent domain so staff stay signed in acrossapp.<root>and tenant subdomains. Symptom when wrong: logged out between subdomains,ActionController::InvalidAuthenticityToken. -
Host authorization: allow the root domain or requests die with "Blocked hosts":
# server/config/environments/development.rb (and production equivalent) if (root_domain = ENV['SPREE_ROOT_DOMAIN']).present? config.hosts << ".#{root_domain}" end -
Customer model: after the generator adds
SpreeMultiTenant::CustomerUserConcern, remove Devise's:validatablefrom the customer model — it enforces globally-unique emails; the concern re-validates per tenant. Don't add other Devise modules. The admin user model keeps:validatable(staff emails are global). -
config/initializers/spree_multi_tenant.rb:SpreeMultiTenant::Config[:app_subdomain] = 'app' # app.example.com — signup at /tenants/new SpreeMultiTenant::Config[:mail_from_name] = ENV.fetch('MAIL_FROM_NAME', 'Your SaaS') SpreeMultiTenant::Config[:mail_from_address] = ENV.fetch('MAIL_FROM_ADDRESS', "support@#{Spree.root_domain}")mail_from_*is used for staff-facing mail (invitations, exports). Customer emails use the tenant store's name and setReply-Toto the store's email.
Writing tenant-aware code
New models
Tenanted models inherit from SpreeMultiTenant::Base (automatic if you use Spree.base_class) and need a tenant_id column. Don't declare belongs_to :tenant — the gem does.
spree generate model Spree::Brand tenant:references name:string --parent SpreeMultiTenant::Base
If you use spree generate api_resource / spree:model, add the tenant_id column and index yourself, and include tenant_id in any unique DB index ([:tenant_id, :slug]).
Forcing / escaping tenant context
SpreeMultiTenant.with_tenant(tenant) do
# everything here is scoped to tenant
end
SpreeMultiTenant.without_tenant do
# cross-tenant: platform billing, global reports, maintenance
end
Console / rails runner / rake tasks start with no tenant:
SpreeMultiTenant.current_tenant = Spree::Store.find_by(code: 'my_store').tenant
Spree::Current.store = Spree::Store.find_by(code: 'my_store') # Spree 6 code also expects a current store
Provisioning a new merchant's store
Seed it inside the tenant with Spree::Seeds::StoreResources.call(store: store) (per-store seeds — channels, roles, payment methods, API keys, … — without touching other stores), then Spree::Stores::ProvisionDefaults.call(store:, country:) for the country-shaped defaults (market, warehouse, delivery zones, pickup). Optional demo data: Spree::SampleData::LoadJob.perform_later(store.id). See spree-data-model.
Background jobs
Jobs enqueued while a tenant is current run in that tenant's context. Jobs enqueued outside one (cron/recurring jobs, without_tenant blocks) must iterate tenants explicitly:
Spree::Tenant.find_each do |tenant|
SpreeMultiTenant.with_tenant(tenant) { MyApp::NightlySyncJob.perform_later }
end
Gotchas
insert_all/upsert_all/ raw SQL bypass tenancy — settenant_idon every row yourself or you create orphaned/cross-tenant rows.Spree::Store.default/Spree::Store.firstin tenant code returns the wrong tenant's store — usecurrent_store/Spree::Current.store.- Unique DB indexes without
tenant_idmake the second tenant's "same" slug/SKU fail at the DB level even though validations pass. localhostfor local dev breaks cookies; uselvh.meorlocaltest.me.- Keygen credentials missing in the Docker build step (not just runtime) →
bundle install401s. - Tenanted records (including webhook endpoints) are only visible inside their tenant; a subscriber or job doing cross-tenant work must use
without_tenantdeliberately. - Third-party extensions that write with
insert_allor define their own base class may need atenant_idmigration and decorator — test each extension with two tenants.
Where to read further
node_modules/@spree/docs/dist/developer/multi-tenant/quickstart.mdnode_modules/@spree/docs/dist/developer/multi-tenant/core-concepts.mdnode_modules/@spree/docs/dist/developer/multi-tenant/configuration.md- https://spreecommerce.org/pricing (Enterprise license)
- Related skills:
spree-data-model(stores, channels),spree-marketplace,spree-deployment,spree-security