使い方
- インストールコマンドをコピーし、自分の環境へ導入する前にソースを確認します。
- 必要なツール、認証情報、対応環境を原文で確認します。
- 入力と成果物を指定し、出力を資料と照合します。
npx skills add https://github.com/spree/agent-skills --skill spree-multi-tenant開始前の確認
- 導入にはモデル契約、API枠、ファイルへのアクセス許可は含まれません。
- スター数や導入数は活動の指標であり、独立した品質・安全性試験ではありません。
見出し、例、コード、表、リンク、参照画像を含む原文を表示しています。
Multi-Tenant (SaaS) Spree
Enterprise only. Multi-tenancy ships in the licensedspree_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 set `tenant_id` automatically.
- 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.rb with domain constraints (and mounts the tenant app on the app subdomain), includes SpreeMultiTenant::CustomerUserConcern in the customer model, and changes the admin user's parent to Spree::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=3COOKIE_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 `:validatable` from 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 set Reply-To to 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::BaseIf 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
endConsole / 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 storeProvisioning 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 }
endGotchas
- `insert_all` / `upsert_all` / raw SQL bypass tenancy — set
tenant_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

