orchardcore-tenants

Skill for configuring multi-tenancy in Orchard Core. Covers tenant creation, SaaS setup, tenant configuration, testing with tenants using different recipes (Blog, Agency), and tenant management APIs.

SKILL.md

GitHub

Orchard Core Tenants - Prompt Templates

Configure Multi-Tenancy

You are an Orchard Core expert. Generate tenant configurations and multi-tenancy setup for Orchard Core.

Guidelines

  • Orchard Core supports multi-tenancy natively; the SaaS setup recipe enables tenant management on the Default tenant.
  • Each tenant is an isolated site sharing the same application instance.
  • Tenants can have separate databases or share a database with table prefix isolation, or with a separate schema when the provider supports schemas.
  • The Default tenant is the main shell that manages other tenants. Enable OrchardCore.Tenants on it to manage tenants under Multi-Tenancy → Tenants (ManageTenants permission).
  • Tenants can use any setup recipe (Blog, Agency, Blank, custom).
  • URL prefixes or hostnames route requests to the correct tenant.
  • A tenant name is also used as a folder name (for example App_Data/Sites/{Tenant} and the tenant media root), so the admin UI, the Tenants API, and the CreateTenantTask workflow activity all validate it with ITenantValidator.
  • Configure the module under OrchardCore:Tenants (the OrchardCore_Tenants section name is deprecated).
  • Other features: OrchardCore.Tenants.FileProvider (per-tenant static files from App_Data/Sites/{Tenant}/wwwroot, e.g. robots.txt), OrchardCore.Tenants.FeatureProfiles, and OrchardCore.Tenants.Distributed (keeps tenant states in sync across instances; see orchardcore-shells).
  • Always seal classes.

Enabling Multi-Tenancy

Use the SaaS recipe during setup to get tenant management out of the box, or enable manually:

{
  "steps": [
    {
      "name": "Feature",
      "enable": [
        "OrchardCore.Tenants"
      ],
      "disable": []
    }
  ]
}

Setting Up with the SaaS Recipe

# Create a new project
dotnet new occms -n MyMultiTenantApp
cd MyMultiTenantApp
dotnet run

# During setup:
# - Choose the "SaaS" recipe
# - Use Sqlite for quick testing
# - Set admin credentials

After setup, navigate to Multi-Tenancy → Tenants in the admin panel to create and manage tenants.

Creating a Tenant via Admin UI

  1. Navigate to Multi-Tenancy → Tenants.
  2. Click Add Tenant.
  3. Set the Name (technical name, letters, digits and underscores; it becomes a folder name).
  4. Set the URL Prefix (e.g., blog) or URL Host (e.g., blog.example.com).
  5. Select a Recipe (Blog, Blank, etc.) and optionally a Feature Profile.
  6. Choose a Database Provider, and a Table Prefix or Schema if sharing a database (unless generated by a pattern).
  7. Click Create, then Setup to initialize the tenant.

The tenants list renders through the shared AdminList shape and supports bulk Enable, Disable, and (when allowed) Remove.

Creating a Tenant via Tenants API

The api/tenants endpoints use the Api authentication scheme (for example an OpenID bearer token) and require ManageTenants. Available actions are create, edit, setup, enable/{tenantName}, disable/{tenantName}, and remove/{tenantName}.

curl -X POST https://localhost:5001/api/tenants/create \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer {{token}}" \
  -d '{
    "Name": "{{TenantName}}",
    "RequestUrlPrefix": "{{UrlPrefix}}",
    "Description": "{{Description}}",
    "RecipeName": "Blog",
    "DatabaseProvider": "Sqlite",
    "FeatureProfiles": [],
    "Category": ""
  }'

Setting Up a Tenant Programmatically

Pass RecipeName for a harvested recipe, or Recipe with the recipe JSON; an inline recipe is written to the tenant-scoped temporary directory (ITempDirectoryProvider, OrchardCore:TempDirectory:Path) before it runs.

curl -X POST https://localhost:5001/api/tenants/setup \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer {{token}}" \
  -d '{
    "Name": "{{TenantName}}",
    "SiteName": "{{SiteName}}",
    "DatabaseProvider": "Sqlite",
    "RecipeName": "Blog",
    "UserName": "admin",
    "Email": "admin@{{tenant}}.example.com",
    "Password": "{{SecurePassword}}",
    "SiteTimeZone": "UTC"
  }'

Tenant States

ShellSettings.State is a TenantState: Uninitialized (created, not set up), Initializing (setup running), Running, Disabled (set up but turned off), or Invalid. Check it with IsUninitialized(), IsInitializing(), IsRunning(), IsDisabled(), IsInitialized() (running or disabled), IsRemovable() (uninitialized or disabled) and IsDefaultShell(); set it with AsUninitialized(), AsRunning(), AsDisabled(). All live in OrchardCore.Environment.Shell.

Creating a Tenant in Code

This is what the admin Create action does. Run it on the Default tenant with OrchardCore.Tenants enabled, and validate the input with ITenantValidator (namespace OrchardCore.Tenants.Services) first when it comes from users.

using OrchardCore.Environment.Shell;

public sealed class TenantProvisioner
{
    private readonly IShellHost _shellHost;
    private readonly IShellSettingsManager _shellSettingsManager;

    public TenantProvisioner(IShellHost shellHost, IShellSettingsManager shellSettingsManager)
    {
        _shellHost = shellHost;
        _shellSettingsManager = shellSettingsManager;
    }

    public async Task CreateAsync(string name, string urlPrefix)
    {
        // Seeded from configuration; dispose it once the shell host has its own copy.
        using var shellSettings = _shellSettingsManager
            .CreateDefaultSettings()
            .AsUninitialized()
            .AsDisposable();

        shellSettings.Name = name;
        shellSettings.RequestUrlPrefix = urlPrefix;
        shellSettings.RequestUrlHost = string.Empty;

        shellSettings["Description"] = $"{name} site";
        shellSettings["DatabaseProvider"] = "Sqlite";
        shellSettings["RecipeName"] = "Blog";
        shellSettings["FeatureProfile"] = "standard";
        shellSettings["Secret"] = Guid.NewGuid().ToString();

        // Stamps a new VersionId, saves the settings and reloads the shell.
        await _shellHost.UpdateShellSettingsAsync(shellSettings);
    }
}
Key Meaning
DatabaseProvider, ConnectionString, TablePrefix, Schema Database settings applied at setup.
RecipeName Setup recipe preselected for the tenant.
FeatureProfile Comma-separated feature profile names.
Secret Token secret used for the setup link.
Category, Description Shown and filterable in the tenants list.

The new tenant is Uninitialized; it still needs setup (database schema, admin user, recipe). Use Setup in the tenants list, POST api/tenants/setup, or ISetupService.SetupAsync(SetupContext) with SetupConstants property keys (SiteName, AdminUsername, AdminEmail, AdminPassword, SiteTimeZone, DatabaseProvider, DatabaseConnectionString, DatabaseTablePrefix, DatabaseSchema), which is what the API action does.

Creating Tenants from Workflows

With OrchardCore.Workflows enabled, the tenant activities are Create Tenant Task, Setup Tenant Task, Enable Tenant Task, and Disable Tenant Task. CreateTenantTask runs the same ITenantValidator validation as the admin UI and API: an invalid name, a duplicate URL prefix and host, or an unknown feature profile takes the Failed outcome and the validation errors are assigned to LastResult. On success it takes Done with the new ShellSettings in LastResult.

Testing with Blog Tenant

# 1. Start the application and set up the Default shell with the SaaS recipe.
dotnet run

# 2. Create a "Blog" tenant in Multi-Tenancy > Tenants:
#    - Name: Blog
#    - URL Prefix: blog
#    - Recipe: Blog
#    - Database: Sqlite

# 3. Set up the blog tenant, then browse https://localhost:5001/blog

Tenant Configuration in appsettings.json

Each tenant can be configured under a section named after it:

{
  "OrchardCore": {
    "Default": {
      "State": "Running",
      "RequestUrlHost": "",
      "RequestUrlPrefix": ""
    },
    "{{TenantName}}": {
      "State": "Running",
      "RequestUrlHost": "",
      "RequestUrlPrefix": "{{UrlPrefix}}",
      "DatabaseProvider": "Sqlite",
      "ConnectionString": "",
      "TablePrefix": "{{TablePrefix}}",
      "Schema": "{{Schema}}"
    }
  }
}

Tenants Module Options

Configure TenantsOptions under OrchardCore:Tenants:

{
  "OrchardCore": {
    "Tenants": {
      "TenantRemovalAllowed": false,
      "RequireTablePrefix": true,
      "TablePrefixPattern": "{{ ShellSettings.Name }}",
      "SchemaPattern": "dbo"
    }
  }
}
  • TenantRemovalAllowed allows removing a tenant that is not set up yet or is disabled (default false).
  • RequireTablePrefix requires a non-empty table prefix when creating a tenant or editing an uninitialized one, for providers that support prefixes.
  • TablePrefixPattern and SchemaPattern are Fluid templates with ShellSettings in scope. A pattern generates the value and removes the manual field from the tenant UI. Generated and manual values must be valid SQL identifiers (letters, digits, underscores; starting with a letter or underscore).
  • The built-in SQLite provider does not share a single database file between tenants through these patterns.

When OrchardCore:DatabaseProvider is configured, the provider is fixed and is not selectable in the Setup or Tenants UI. When both OrchardCore:DatabaseProvider and OrchardCore:ConnectionString are configured, the connection string is fixed as well. Setup asks for a connection string only when the selected provider requires one and the tenant has no saved value.

Feature Profiles for Tenants

Enable OrchardCore.Tenants.FeatureProfiles on the Default tenant, then manage profiles under Multi-Tenancy → Feature Profiles (ManageTenantFeatureProfiles). Rules are applied in order; Exclude and Include accept * and ? wildcards. Select a profile when creating or editing a tenant, through the API (FeatureProfiles), or in Auto Setup.

{
  "steps": [
    {
      "name": "Feature",
      "enable": [
        "OrchardCore.Tenants",
        "OrchardCore.Tenants.FeatureProfiles"
      ],
      "disable": []
    },
    {
      "name": "FeatureProfiles",
      "FeatureProfiles": {
        "standard": {
          "FeatureRules": [
            {
              "Rule": "Exclude",
              "Expression": "OrchardCore.Tenants*"
            },
            {
              "Rule": "Exclude",
              "Expression": "OrchardCore.MiniProfiler"
            }
          ]
        }
      }
    }
  ]
}

The All Feature Profiles deployment step exports the profiles.

Accessing Tenant Information in Code

using OrchardCore.Environment.Shell;

public sealed class TenantInfoService
{
    private readonly ShellSettings _shellSettings;

    public TenantInfoService(ShellSettings shellSettings)
    {
        _shellSettings = shellSettings;
    }

    public string GetCurrentTenantName()
    {
        return _shellSettings.Name;
    }

    public string GetRequestUrlPrefix()
    {
        return _shellSettings.RequestUrlPrefix;
    }
}

Running Tenant-Specific Code

using OrchardCore.Environment.Shell;
using OrchardCore.Environment.Shell.Scope;

public sealed class TenantOperationService
{
    private readonly IShellHost _shellHost;

    public TenantOperationService(IShellHost shellHost)
    {
        _shellHost = shellHost;
    }

    public async Task RunInTenantAsync(string tenantName, Func<ShellScope, Task> action)
    {
        var shellScope = await _shellHost.GetScopeAsync(tenantName);
        await shellScope.UsingAsync(action);
    }

    public async Task RunInAllRunningTenantsAsync(Func<ShellScope, Task> action)
    {
        foreach (var settings in _shellHost.GetAllSettings().Where(settings => settings.IsRunning()))
        {
            var (scope, success) = await _shellHost.TryGetScopeAsync(settings.Name);

            if (success)
            {
                await scope.UsingAsync(action);
            }
        }
    }
}

Inside the delegate, resolve services from scope.ServiceProvider (or ShellScope.Services); they belong to that tenant. From code already running in a tenant, ShellScope.UsingChildScopeAsync(tenantName, async scope => { ... }) does the same. Never resolve tenant services such as IContentManager or ISession from the application root provider. Scope mechanics are covered in orchardcore-shells.

Gotchas

  • Creating settings is not a usable site; a new tenant must be set up before it serves requests.
  • Dispose the ShellSettings created by CreateDefaultSettings() (.AsDisposable() plus using).
  • Only the Default tenant (ShellSettings.DefaultShellName) manages other tenants.
  • Every module Startup runs per tenant, so a singleton is one instance per tenant, not per application.

Upgrading from 3.x

  • Rename the OrchardCore_Tenants configuration section to OrchardCore:Tenants.
  • CreateTenantTask now validates its input with ITenantValidator; workflows that created tenants with invalid names, duplicate prefix and host, or missing feature profiles now take Failed. Its constructor gained an ITenantValidator parameter, so code that constructs it directly must pass one.
  • Tenant recipe uploads use ITempDirectoryProvider instead of Path.GetTempPath().
  • A theme or module that overrides the tenants list view must render the List context with the <admin-list> tag helper (see orchardcore-admin-lists).