mcpField
S
spree-multi-tenant

spree-multi-tenant

github.com/spreeDevelopment10101 installsUpdated 4w ago
Repository

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

  1. 01Row-level tenant data isolation
  2. 02Host-based tenant resolution
  3. 03Store and customer tenancy model
  4. 04Tenant-scoped jobs and code
  5. 05Enterprise installation guidance

Use cases

  1. 01Building a SaaS for independent merchants
  2. 02Isolating customer data across tenants
  3. 03Provisioning tenant stores and defaults
  4. 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-tenant

After installing, type/spree-multi-tenantin Claude Code to confirm it loaded.

Details

From the project README

Multi-Tenant (SaaS) Spree

Enterprise only. Multi-tenancy ships in the licensed spree_enterprise + spree_multi_tenant gems (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

NeedUse
Many independent merchants, fully isolated data, one deployment (Shopify-style SaaS)Multi-tenant (this skill)
One merchant running several storefronts that share a catalog/customersMultiple Spree::Stores (+ spree_multi_store for cross-store sharing) — see spree-data-model
Many sellers listing products under one shared storefrontMarketplace — see spree-marketplace

How isolation works

  • Spree::Tenant is 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_id column. The gem is built on acts_as_tenant.
  • The gem sets Spree.base_class to SpreeMultiTenant::Base, so every Spree model that inherits from Spree.base_class is tenanted automatically. SpreeMultiTenant::Base also makes spree_base_uniqueness_scope return [: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 as current_tenant in controllers and SpreeMultiTenant.current_tenant elsewhere.
  • One tenant can own several stores. Spree::Store.default returns 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)

  1. Set KEYGEN_ACCOUNT_ID and KEYGEN_LICENSE_KEY in the server environment (and CI, staging, production build environments).

  2. spree eject if you use the Docker CLI flow (gems must be installed into your own image).

  3. 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
    
  4. Docker only: pass the Keygen values to every bundle install in server/Dockerfile as BuildKit secrets (RUN --mount=type=secret,id=keygen_license_key,env=KEYGEN_LICENSE_KEY ...) and declare the secrets in both compose files. Never use ARG/ENV — that bakes the license into image layers.

  5. spree bundle install (or bundle 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:install
    

    This 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=3
  • COOKIE_TLD_LENGTH scopes the session cookie to the parent domain so staff stay signed in across app.<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::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 — set tenant_id on every row yourself or you create orphaned/cross-tenant rows.
  • Spree::Store.default / Spree::Store.first in tenant code returns the wrong tenant's store — use current_store / Spree::Current.store.
  • Unique DB indexes without tenant_id make the second tenant's "same" slug/SKU fail at the DB level even though validations pass.
  • localhost for local dev breaks cookies; use lvh.me or localtest.me.
  • Keygen credentials missing in the Docker build step (not just runtime) → bundle install 401s.
  • Tenanted records (including webhook endpoints) are only visible inside their tenant; a subscriber or job doing cross-tenant work must use without_tenant deliberately.
  • Third-party extensions that write with insert_all or define their own base class may need a tenant_id migration and decorator — test each extension with two tenants.

Where to read further

  • node_modules/@spree/docs/dist/developer/multi-tenant/quickstart.md
  • node_modules/@spree/docs/dist/developer/multi-tenant/core-concepts.md
  • node_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

Comments

Markdown supported