Skip to content

Multi-tenancy ​

A tenant in the Manager is a customer organization. Applications are defined once per project, enabled per tenant, and deployed per environment. One running proxy vhost and one running backend serve all tenants of an application, and the proxy works out which tenant each request belongs to.

The model ​

  • One Application row per product, at the project level. It is not cloned per tenant.
  • A tenant is a customer organization, an account. Membership (user2tenant) answers "which organizations can this user act in".
  • An Environment row (dev, qas or run) per project carries the deployment dimension. Staging and production of one application are one Application row with two deployments.
  • A Tenant2Application row controls which tenants have which applications enabled, and carries that pair's configuration overrides.
  • One proxy vhost and one backend per (application, environment) serve every enabled tenant. Onboarding a customer is a tenant row plus an enablement, not a new deployment.
  • Users who belong to several organizations switch between them in-app. The proxy selects and enforces the active tenant via /_tenant using a signed, membership-checked cookie.

Hierarchy ​

Project (Product / Organization)
  ├─ Applications          (defined once per project)
  │    └─ Services         (one primary + N backends; see Anatomy of a Stack)
  ├─ Environments          (dev · qas · run — first-class entity, optional base_domain)
  │    └─ Deployment       (service × environment [× dedicated tenant]:
  │                         domains, deployment spec)
  └─ Tenants               (customer organizations; deployment_mode shared|dedicated)
       └─ Tenant2Application enablement
                            (enabled flag + per-(tenant, app) settings)

The config cascade is project → application (modules only) → tenant → tenant2application; the Tenant2Application row is the leaf. See Configuration Layers.

Example ​

Project: ACME Platform
  ├─ Applications: Portal, Mobile API   (each defined once)
  ├─ Environments:
  │   ├─ dev   → portal.dev.acme.internal
  │   ├─ qas   → qas.portal.acmecorp.com
  │   └─ run   → portal.acmecorp.com
  └─ Tenants (customers):
      ├─ Globex Inc      (Portal enabled)
      ├─ Initech         (Portal + Mobile API enabled)
      └─ Umbrella Corp   (Portal enabled, custom theme override)

Three customers, two applications, three environments, and still only one Portal deployment per environment. Onboarding a fourth customer is a Tenant row plus a Tenant2Application enablement.

What the proxy does per request ​

  • The proxy injects the active tenant on every request as X-Tenant-ID, after a membership-checked selection. Inbound copies of that header are stripped first, so a client cannot forge it.
  • Configuration is resolved per (application, tenant). See Configuration Layers.
  • Background jobs fan out per enabled tenant from a single trigger delivery: the Manager fires a trigger once per deployment, and the backend lists its enabled tenants itself via GET /api/machine/application/:id/tenants (machine-user auth). There are no per-tenant trigger rows.

User access ​

Users are explicitly members of tenants:

  • No default tenant access. Membership is granted explicitly (user2tenant).
  • Tenant roles are configurable per project over a fixed platform permission vocabulary (see Access Model). The platform seeds member, admin and owner per project, so simple installs never touch the machinery.
  • Audit logs record membership and role changes.

Delegated tenant administration is available: a user holding tenant-admin permissions gets a scoped read surface over their own tenant (members, role definitions, enabled applications, pending invites) and can invite users into it. See Access Model.

Configuration inheritance ​

Settings cascade from project to tenant to the per-(tenant, application) row:

  1. Project settings: defaults for everything in the project.
  2. Application settings: only the module fields (AvailableModules, EnabledModules) take part in the tenant-aware cascade.
  3. Tenant settings: defaults for one customer organization.
  4. Tenant × Application settings: the leaf, on the Tenant2Application row.

More specific settings win. For the exact per-field rules (replace vs. union vs. merge-by-key) see Configuration Layers.

Example ​

The values below are illustrative KeyValuePairs (string keys and values).

Project (ACME Platform):

json
{ "theme": "corporate-blue", "locale": "en", "support_email": "support@acme.com" }

Tenant (Umbrella Corp):

json
{ "theme": "umbrella-dark" }

Tenant × Application (Umbrella Corp × Portal):

json
{ "locale": "es" }

Effective configuration (what Portal sees for Umbrella Corp users):

json
{
  "theme": "umbrella-dark",          // from Tenant
  "locale": "es",                    // from Tenant × Application
  "support_email": "support@acme.com" // from Project
}

The proxy fetches this via GET /api/frontend-config/:application_id/:tenant_id (proxy-secret gated). The handler merges the cascade and returns 404 if the application is not enabled for that tenant. The proxy injects the result into window.__PRODUCTIFY__.config.

Deprecated config route

The tenant-less route GET /api/frontend-config/:application_id is still served. It merges only project and application settings, and the Manager logs a deprecation warning the first time it is called. Move consumers to the tenant-qualified route.

Domains and routing ​

Domains belong to a deployment (service × environment), and an environment's base_domain derives <app>.<base_domain> on top of them. The proxy's per-application vhost configuration is rendered from Manager data and pushed to the running proxy via Caddy's admin API. Adding a domain or application is a Manager operation, not a Caddyfile edit:

portal.dev.acme.internal     # Portal × dev
qas.portal.acmecorp.com      # Portal × qas
portal.acmecorp.com          # Portal × run

All tenants of an environment share its domain; the active tenant is selected per user rather than per hostname. That is the shared topology, and it is the default. A tenant can instead be made dedicated: it then gets its own hostname per application (<app>.<tenant>.<base_domain>) and its own upstream (<app>--<tenant>), and the tenant is pinned by the rendered vhost rather than chosen by cookie. See Shared vs. dedicated tenants, which also covers the DNS records a dedicated tenant needs.

An unknown host is answered by a rendered catch-all that returns 421 Misdirected Request, never a default application. See Generated Artifacts and Application Routing.

The renderer stamps a minimum proxy version into the pushed config. A proxy plugin that does not understand the config rejects it loudly rather than silently misrouting.

Tenant switching ​

Users who belong to more than one tenant pick and switch organizations without re-authenticating:

  • On first visit, the proxy redirects multi-tenant users to a hosted selector (GET /_tenant/select?redirect=…, server-rendered). Single-tenant users are auto-selected.
  • The selection is stored in an HMAC-signed cookie scoped per application (pfy_tenant_<app_id>) and re-validated against membership on every request. The proxy never trusts a caller-supplied tenant_id.
  • Applications read available_tenants (id and name) from window.__PRODUCTIFY__ to render their own in-app switcher, and POST /_tenant {tenant_id} to switch (204 on success).
  • GET /_tenant returns {current, available: [{id, name, role}]} from the authoritative membership endpoint (cached per application with an ETag).
  • After a switch, the proxy injects the new X-Tenant-ID on the next request. Applications refetch config and realtime layers resubscribe.
  • GET /_tenant/logout clears the selection cookie and redirects to the portal logout; SPAs read the URL from window.__PRODUCTIFY__.logout_url.

On a dedicated host there is nothing to switch: the cookie is never read, the catalog holds exactly the pinned tenant, and POST /_tenant answers 409. See Shared vs. dedicated tenants.

The membership endpoint GET /api/proxy/user/:user_id/tenants?application_id=… (proxy-secret gated) is the single source of truth for both the selector and per-request enforcement. It returns the tenants the user is a member of and that have the application enabled, each with the user's role name and permission set. A dedicated tenant whose own vhost is rendered is omitted from this list on the shared host; a pinned vhost asks about that one tenant with &tenant_id=.

Backend protocol ​

One registration per (application, environment) via an environment-scoped machine user, not one per tenant. Triggers stay per-application and fire once per deployment; the backend fans out over its enabled tenants itself. Config clients take a tenant dimension (getFrontendConfig(appId, tenantId) in the Node client, FrontendConfigForTenant in the Go client). See Backend Registration.

Use cases ​

Multi-tenant SaaS ​

One deployment, many customers:

Project: SaaS Product
  ├─ Applications: Web App, API
  ├─ Environments: qas, run
  └─ Tenants: Customer A, Customer B, Customer C
  • Onboard a customer with two Manager rows (Tenant + Tenant2Application).
  • Sell different applications to different tenants via enablement.
  • Put per-customer configuration on the tenant2application leaf.

Multi-environment delivery ​

Environments separate dev, qas and run of the same application row:

Project: E-commerce Platform
  ├─ Application: Shop
  └─ Environments: dev → qas → run

There is nothing to clone between environments. The deprecated pfy application promote refuses; copy per-tenant configuration between join rows with pfy application config-copy if you need to.

Multi-region deployment ​

For region-specific data residency, run one Manager per region and route customers to their regional instance. Within each region the tenant model above applies unchanged.

Best practices ​

  1. One project per product or organization.
  2. One tenant per customer organization.
  3. Environments carry dev/qas/run. Never model an environment as a tenant.
  4. Set project-level defaults; use the tenant2application leaf only for real per-customer differences.
  5. Trust the injected X-Tenant-ID in your application, and nothing else, for tenant context. It is backed by a signed, membership-checked cookie (or a vhost pin).
  6. Review memberships and role definitions regularly. Role edits are audit-logged.

Tenant administration ​

Creating tenants ​

Tenants are created within a project, via the Manager UI, pfy tenant create --project <slug> --slug <slug> --name <name>, or the GraphQL API. See Managing Tenants.

Enabling applications ​

  1. Open the Application → Tenants tab (or Tenant → Applications).
  2. Enable the application for the tenant, or from the CLI:
    bash
    pfy application enable --app portal --tenant globex --project acme
    pfy application list-tenants --app portal --project acme
  3. Optionally add tenant × application configuration overrides.

An application that is not enabled for a tenant is invisible to that tenant's users: in the selector, in configuration (404), and on the ESB.

Isolation guarantees ​

What is isolated ​

  • Configuration: per tenant and per tenant × application.
  • User access: explicit membership; tenant selection is membership-checked and signed.
  • Audit logs: activity is recorded per project, including its tenants.
  • API access: filtered by the injected tenant context.

What is shared ​

  • Application code: the same running instance for all tenants (unless the tenant is dedicated).
  • Infrastructure: shared servers (unless dedicated).
  • Data stores: a shared application's managed data stores are shared too. Your application filters rows by X-Tenant-ID; the platform does not enforce row-level isolation inside your database.

Troubleshooting ​

User cannot access a tenant ​

The user is not a member of the tenant, or the application is not enabled for it.

  1. Check Tenant → Members; add the user with a role.
  2. Check the application is enabled for the tenant (pfy application list-tenants --app <slug> --project <slug>).

Data appearing in the wrong tenant ​

Treat this as critical and report it immediately.

  1. Verify the tenant ID on the affected rows.
  2. Check that application code filters on the injected X-Tenant-ID.
  3. Review audit logs for unauthorized access.

See also ​