Skip to content

Multi-tenancy

Manager provides customer-organization isolation through a Project → Applications + Environments + Tenants model. Applications are defined once per project, enabled per tenant, and deployed per environment — one deployed proxy and one deployed backend serve all tenants of an application.

Shipped in the Wave 3 release train

This is the model the Manager implements as of the Wave 3 re-architecture. It replaces the earlier model where an Application belonged to exactly one Tenant and tenants doubled as environments (tenant.type = local|dev|qas|run). If you are upgrading, read the Wave 3 Migration Guide — the schema, the config route, and the proxy config all change in one coordinated release.

What is Multi-tenancy in Manager?

Tenants represent customer organizations (accounts); environments carry the dev/qas/run dimension separately:

  • Application defined once — one Application row per product, living at the project level, not cloned per tenant.
  • Tenant = customer organizationtenant.type is gone. A tenant is an account, and membership (user2tenant) answers "which organizations can this user act in".
  • Environments are first-class — a dev | qas | run Environment entity per project carries the deployment dimension. Staging and production of one application are one Application row with two deployments.
  • Per-tenant enablement — a Tenant2Application join controls which tenants have which applications enabled and carries per-(tenant, application) configuration overrides.
  • Single deployment — one running proxy and one running backend per (application, environment) serve every enabled tenant. Onboarding a customer is a tenant row plus an enablement, not a new deployment.
  • Tenant switching — users who belong to several organizations switch between them in-app; the proxy selects and enforces the active tenant via /_tenant on a signed, membership-checked cookie.

Hierarchy

Project (Product / Organization)
  ├─ Applications          (defined once per project)
  ├─ Environments          (dev · qas · run — first-class entity)
  │    └─ Deployment       (application × environment:
  │                         domains, deployment spec, machine user,
  │                         registered backend)
  └─ Tenants               (customer organizations)
       └─ Tenant2Application enablement
                            (enabled flag + per-(tenant, app) config overrides)

The config cascade is project ← tenant ← tenant2application; the Tenant2Application row is the leaf.

Example Structure

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, not a new deployment.

Data Isolation

Database Level

Each tenant's data is isolated at the database level:

  • Tenant ID on tenant-scoped records
  • Query-level filtering in GraphQL resolvers (there is no database row-level security)
  • No cross-tenant queries allowed
  • Audit trail per project, covering activity in its tenants

Application Level

Applications are tenant-aware:

  • The active tenant arrives on every request (X-Tenant-ID, injected by the proxy after a membership-checked selection).
  • 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 iterates its enabled tenants itself (it lists them via GET /api/machine/application/:id/tenants). There are no per-tenant trigger rows.

User Access

Users are explicitly members of tenants:

  • No default tenant access — membership is granted explicitly.
  • 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 track membership and role changes.

Self-service is phased

Wave 3 ships the rationalized role model and opt-in role/permission forwarding. Delegated tenant administration, invitations, and end-user self-service are Wave 4 (gated on the security release). See Access Model.

Configuration Inheritance

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

  1. Project Settings — defaults for everything in the project.
  2. Tenant Settings — defaults for one customer organization.
  3. Tenant × Application Settings — the leaf: settings on the Tenant2Application row for this customer's use of this application.

More specific settings win: tenant2application overrides tenant, tenant overrides project.

Example

Project Level (ACME Platform):

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

Tenant Level (Umbrella Corp):

json
{
  "theme": "umbrella-dark",
  "features": ["analytics", "monitoring"]
}

Tenant × Application Level (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 (override)
  "support_email": "support@acme.com", // from Project
  "features": ["analytics", "monitoring"] // from Tenant
}

The backend fetches this via GET /api/frontend-config/:application_id/:tenant_id; the handler merges project → tenant → tenant2application settings and returns 404 if the application is not enabled for that tenant. The proxy injects the same resolution into window.__PRODUCTIFY__.

Deprecated config route

The old tenant-less route GET /api/frontend-config/:application_id is kept for one release, serving project + application defaults, and is deprecated. Move consumers to the tenant-qualified route. See Migration Guide.

Domains & Routing

Domains belong to a deployment (application × environment). 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, at which point it gets its own hostname per application (app.tenant.<base>) 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 requires. An unknown host is answered by a rendered catch-all that returns 421 Misdirected Request — never a default application. See Deployment: 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 — the version handshake that keeps an old proxy and a new Manager from disagreeing.

Tenant Switching

Users who belong to more than one tenant pick and switch organizations without re-authenticating. The mechanism is built on the signed, membership-checked cookie shipped in the security release:

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

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.

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 gain a tenant dimension (getFrontendConfig(appId, tenantId) / FrontendConfigForTenant). 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 enablement).
  • Sell different applications to different tenants via enablement.
  • Per-customer configuration overrides on the tenant2application leaf, without config drift.

Multi-Environment Delivery

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

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

Copying configuration between environments replaces the old cross-tenant application promote — there is nothing to clone.

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

Project & Tenant Design

  1. One project per product/organization.
  2. Tenants are customers — one tenant per customer organization.
  3. Environments carry dev/qas/run — never model environments as tenants.
  4. Set project-level defaults; use the tenant2application leaf only for real per-customer differences.

Security

  1. Isolate the run environment — production access is separate from dev/qas.
  2. Verify tenant context in all operations (the injected X-Tenant-ID — now backed by a signed, membership-checked cookie — is the source of truth).
  3. Audit cross-tenant access attempts.
  4. 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, 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 (pfy application enable --tenant <slug>).
  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

  • Database records — tenant-scoped data separated by tenant ID.
  • Configuration — per tenant and per tenant × application.
  • User access — explicit membership, and tenant selection is now membership-checked and signed.
  • Audit logs — activity tracked per project.
  • API access — filtered by the injected tenant context.

What is Shared

  • Application code — same codebase and same running instance for all tenants.
  • Infrastructure — shared servers (unless dedicated).
  • Database instance — same DB, different data.

Troubleshooting

User Cannot Access Tenant

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

Solution:

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

Data Appearing in Wrong Tenant

Critical issue — report immediately.

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

See Also