spree-multi-tenant
Use when building a SaaS / multi-tenant platform on Spree 6 with the Enterprise `spree_multi_tenant` gem — many isolated merchants on one deployment, each with its own stores, catalog, customers and orders. Covers licensing and install (`spree_enterprise:install`, `spree_multi_tenant:install`), `Spree::Tenant` and row-level `tenant_id` isolation, `SpreeMultiTenant::Base`, `with_tenant` / `without_tenant`, console tenant context, `insert_all`/`upsert_all` pitfalls, background jobs, `Spree.root_domain`, cookie domain and host authorization, global staff vs per-tenant customers. Common phrasings include "multi-tenant Spree", "SaaS on Spree", "each merchant gets a subdomain", "tenant isolation", "records leaking between tenants", "set the tenant in the console", "Blocked hosts lvh.me".
How do I install this agent skill?
npx skills add https://github.com/spree/agent-skills --skill spree-multi-tenantIs this agent skill safe to install?
- Gen Agent Trust Hubpass
This skill provides secure guidelines and setup instructions for the Spree Multi-Tenant enterprise extension. It follows industry best practices for secret management, specifically recommending the use of environment variables and Docker BuildKit secrets for license keys, and provides explicit developer guidance to prevent data isolation vulnerabilities.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Multi-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
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/spree/agent-skills/spree-multi-tenant">View spree-multi-tenant on skillZs</a>