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
Applicationrow 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
Environmentrow (dev,qasorrun) per project carries the deployment dimension. Staging and production of one application are oneApplicationrow with two deployments. - A
Tenant2Applicationrow 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
/_tenantusing 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,adminandownerper 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:
- Project settings: defaults for everything in the project.
- Application settings: only the module fields (
AvailableModules,EnabledModules) take part in the tenant-aware cascade. - Tenant settings: defaults for one customer organization.
- Tenant × Application settings: the leaf, on the
Tenant2Applicationrow.
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):
{ "theme": "corporate-blue", "locale": "en", "support_email": "support@acme.com" }Tenant (Umbrella Corp):
{ "theme": "umbrella-dark" }Tenant × Application (Umbrella Corp × Portal):
{ "locale": "es" }Effective configuration (what Portal sees for Umbrella Corp users):
{
"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 × runAll 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-suppliedtenant_id. - Applications read
available_tenants(id and name) fromwindow.__PRODUCTIFY__to render their own in-app switcher, andPOST /_tenant {tenant_id}to switch (204on success). GET /_tenantreturns{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-IDon the next request. Applications refetch config and realtime layers resubscribe. GET /_tenant/logoutclears the selection cookie and redirects to the portal logout; SPAs read the URL fromwindow.__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 → runThere 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
- One project per product or organization.
- One tenant per customer organization.
- Environments carry dev/qas/run. Never model an environment as a tenant.
- Set project-level defaults; use the tenant2application leaf only for real per-customer differences.
- Trust the injected
X-Tenant-IDin your application, and nothing else, for tenant context. It is backed by a signed, membership-checked cookie (or a vhost pin). - 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
- Open the Application → Tenants tab (or Tenant → Applications).
- 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 - 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.
- Check Tenant → Members; add the user with a role.
- 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.
- Verify the tenant ID on the affected rows.
- Check that application code filters on the injected
X-Tenant-ID. - Review audit logs for unauthorized access.
See also
- Managing Tenants: tenant CRUD
- Shared vs. dedicated tenants: deployment topology, DNS, pilet pins
- Configuration Layers: settings inheritance
- Anatomy of a Stack: services and deployments
- Access Model: roles, permissions, precedence
- Generated Artifacts: install.yaml and rendered deployments