Skip to content

Shared vs. dedicated tenants ​

Every tenant is shared by default: one backend per application serves all of them, and the proxy works out which tenant a request belongs to from the caller's signed selection cookie.

A tenant can instead be made dedicated. It then gets its own deployment, its own upstream and its own hostname for each application, and on those hostnames the tenant is fixed by the rendered proxy config instead of chosen by the browser.

Dedicated is a move, not a copy. Once a dedicated tenant's own hostname is actually being served, that tenant is no longer reachable on the shared host at all: it disappears from the shared host's tenant chooser, and a cookie naming it there is refused. This is what makes the isolation real. See Dedicated means moved off the shared host.

Both models coexist in one install. Dedicating one tenant changes nothing for the others.

At a glance ​

SharedDedicated
Backends per appone, serving all tenantsone per dedicated tenant, plus the shared one
Hostname<app>.<base_domain><app>.<tenant>.<base_domain>
Upstream<app><app>--<tenant>
Tenant resolutionsigned cookie, re-validated per requestpinned in the vhost; the cookie is never read
Tenant switching/_tenant choosernothing to choose; the host is the tenant
On the shared hostselectablenot offered, and not servable, once its own host is rendered
Onboarding a tenantManager rows onlyManager rows plus a DNS record
Costone backendone backend per dedicated tenant
Blast radiussharedisolated

When to use which ​

Stay shared unless something below forces your hand. Shared is cheaper, has no DNS step, and onboarding a tenant is a database row.

Go dedicated when:

  • Isolation is contractual: a customer requires that their traffic never shares a process with another tenant's. This is a compliance answer, not a technical one, and it is the only one a shared backend cannot give.
  • Blast radius must be bounded: one tenant's load, or one tenant's bad data triggering a crash loop, must not take the others down.
  • Resource profiles diverge sharply: one tenant needs 8 GB and the rest need 512 MB. Sizing the shared backend for the outlier wastes it on everyone.
  • The lifecycle must diverge: a tenant needs to be pinned to an older application version, or upgraded on their own schedule.
  • A tenant needs their own vanity domain. Explicit domains work in both modes, but the dedicated row is the natural place for them.

Do not go dedicated only for per-tenant configuration or branding. Those are settings-cascade concerns (project → tenant → tenant × application) and work the same on a shared backend, at none of the cost. See Configuration layers.

The consequence people miss: DNS ​

A DNS wildcard matches exactly one label.

*.apps.example.com          matches  operations.apps.example.com          ✅
*.apps.example.com          matches  operations.acme.apps.example.com     ❌

A dedicated hostname has an extra label, <app>.<tenant>.<base>, so the wildcard a shared install runs on does not cover it. A shared install trains you to believe app hostnames are free: enable an app for a tenant, and it is reachable. Dedicate a tenant and that stops being true.

Two workable shapes:

# A. one wildcard per dedicated tenant — covers ALL of that tenant's apps
*.acme.apps.example.com.     CNAME   proxy.example.com.

# B. one record per dedicated (app, tenant) host — no wildcard needed
operations.acme.apps.example.com.  CNAME  proxy.example.com.
analytics.acme.apps.example.com.   CNAME  proxy.example.com.

A is why the topology is per-tenant-all-apps rather than per-(tenant, application): one record onboards the tenant's entire catalogue, including applications enabled later. B avoids wildcards, at a record per application.

A multi-level *.*.apps.example.com covers both shared and dedicated hosts at once, but provider support is uneven and ACME issuance for it is its own problem. Prefer A or B.

None of this can be provisioned for you, so dedicating a tenant returns a DNS checklist rather than pretending it is automatic:

bash
$ pfy tenant update acme --project retail --deployment-mode dedicated
Tenant acme deployment mode set to dedicated
DNS checklist (2 records):
  - operations.acme.apps.example.com  CNAME proxy.example.com
  - analytics.acme.apps.example.com   CNAME proxy.example.com
The tenant's hostnames will not resolve until these records exist.

TLS needs no extra machinery. Rendered hosts are explicit site addresses, so Caddy issues a per-host certificate exactly as it already does for shared apps.

How the pin works ​

On a dedicated vhost the Manager stamps a literal tenant UUID into the rendered config. Simplified (the real block also carries the platform routes for /auth, /esb, /pilet-feed, /pilets, /language-packs and /comms, and the pin is repeated on every browser-identity directive in them):

caddyfile
# app 7c9e6679-7425-40de-944b-e07fc1f90ae7 — auto-rendered vhost; DO NOT hand-edit
# dedicated tenant acme (11111111-1111-1111-1111-111111111111) — every browser-identity directive below is
# PINNED to it; the selected-tenant cookie is not consulted on this host.
operations.acme.apps.example.com {
	route /* {
		productify_protect 7c9e6679-7425-40de-944b-e07fc1f90ae7 with pocketpolicy {
			tenant 11111111-1111-1111-1111-111111111111
		}
		reverse_proxy operations--acme
	}
}

The first argument to productify_protect is the application's id, and the reverse_proxy target is the Nomad service name (<app>--<tenant>); the port is resolved by service discovery, not written here.

Three properties, each a security claim:

  1. The cookie is never consulted. A stale, forged or foreign selection cookie has no effect on a dedicated host. It is not "rejected"; it is not read. A user who legitimately selected another tenant on the shared host is still served the pinned tenant here.
  2. Membership is still enforced, and it fails closed. A pinned host is a narrowing, not a bypass: every request re-checks the caller's membership of the pinned tenant against the Manager. The cookie path fails open on purpose, so a Manager blip does not log everyone out of a selection they legitimately made. On a pinned host an unverifiable membership denies. A non-member gets 403, never a redirect to the chooser (on a pinned host that would only loop).
  3. The host header is not trusted. The tenant comes from the rendered config, which names exact site addresses. It is never parsed out of a runtime Host.

The pin is a literal UUID and never a placeholder. The proxy validates it at Caddyfile parse time (canonical 36-character form). That matters more than it sounds: a Caddyfile is all-or-nothing, so a malformed pin does not degrade one vhost, it makes the proxy reject the entire config and keep serving its last good one.

The selector on a pinned host ​

/_tenant stays mounted, because single-page apps call it unconditionally, and tells the truth about a host with one tenant and no choice:

RequestAnswer
GET /_tenantcatalog of one: the pinned tenant, already current
POST /_tenant409: nothing to switch to (not a 204 that silently changes nothing)
GET /_tenant/select303 to /: an empty chooser is a dead end
/_tenant/logoutunchanged, so "Sign out" behaves identically

All of them are membership-gated: a non-member gets 403 and is not told which tenant the host serves.

Dedicated means moved off the shared host ​

A dedicated tenant is removed from the shared host, not given an additional one. Once its own hostname is being served, on <app>.<base_domain> that tenant:

  • no longer appears in the /_tenant catalog, so the chooser does not offer it;
  • cannot be selected by naming it explicitly. A cookie or a POST /_tenant naming that tenant is refused.

This is not cosmetic. The shared host is backed by the shared upstream (<app>), while the dedicated host is backed by <app>--<tenant>. These are different processes, usually against different data. If a dedicated tenant stayed selectable on the shared host, the same user reaching the two hostnames would be served by two different stacks, and their writes would split across both. That is worse than no isolation, because it is invisible: both hosts answer 200.

The exclusion keys on a rendered vhost, not on the mode flag ​

This is what makes lockout impossible. A tenant is excluded from the shared host only where a pinned vhost demonstrably exists, meaning all four of:

  1. the tenant is in dedicated mode, and
  2. the application is enabled for it, and
  3. the tenant's deployment row derives at least one hostname (so the environment has a base_domain, or the row lists explicit domains), and
  4. that row belongs to the primary service. A dedicated row for a backend service alone renders no vhost, so it does not count.

That is exactly the condition under which the renderer emits a pinned vhost, so the two cannot drift apart.

The practical consequence: flipping a tenant to dedicated before DNS or base_domain is ready does not strip it off the shared host. It keeps working there until somewhere to move it to has actually been rendered. A tenant is never removed from the host that serves it until another one does.

Known limitation: the rule is per-application, not per-environment ​

The exclusion should apply per (application, environment): a tenant dedicated in prod but not in staging should leave the prod shared host and stay on the staging one. It currently cannot express that. The proxy asks the Manager only ?application_id= when it resolves a caller's tenants; no environment is sent, and adding one is a change on the proxy side.

Given that, the rule errs toward availability: a tenant is excluded only when every environment that serves a shared vhost for this application also serves it a pinned one. The alternative, excluding as soon as any environment has a pinned vhost, would lock the tenant out of the shared host of an environment where it has none.

In normal operation the two readings coincide, because setTenantDeploymentMode seeds a dedicated row for every (enabled app, environment) pair that has a shared row, and refuses to dedicate at all when any such environment lacks a base_domain. They diverge only if a new environment gains a shared deployment after the tenant was dedicated: until that environment also gets a dedicated row, the tenant stays selectable on the shared hosts of all environments for that application. Add the dedicated row (or re-run the dedicate) to close the gap.

Per-tenant pilet pins ​

A dedicated vhost loads pilets from a tenant-scoped feed (/pilet-feed/<app>/<tenant>, baked into the rendered route), so a dedicated tenant can be held at a specific pilet version while everyone else moves on. This is the "pin this customer to the old dashboard" case that usually motivates dedicating in the first place.

The pins live on the frontend module config of the tenant × application settings leaf, and resolve through the ordinary settings cascade. Pins merge per pilet: a leaf that pins one pilet does not drop pins inherited from the project or tenant layer.

json
{
  "pilets": {
    "pins": {
      "dashboard": "1.4.2"
    }
  }
}
  • The key is the pilet name; the value is an exact version string.
  • A pin selects among versions that are already uploaded, enabled and signed. It is not an upload channel, and it is not a way around the enabled switch, which is how a bad build gets withdrawn.
  • A pin naming a missing or disabled version is ignored with a warning in the Manager log, never an error. Pins are hand-written, typos are expected, and failing the feed would blank the application for every user of that tenant.
  • Pins for pilets that are not in the tenant's effective EnabledModules are dropped before the config reaches the browser.
  • Resolution order is: the tenant's pin → the project-wide rollout → the newest uploaded version.
  • Pins apply only on dedicated hosts. A shared host serves many tenants, so no single tenant's pins could apply to it.

There is no pin editor UI; write pins through the existing settings surfaces.

Un-dedicating, and the data question ​

bash
pfy tenant update acme --project retail --deployment-mode shared --yes

Un-dedicating deletes the tenant's dedicated deployment rows, so the CLI asks you to type the tenant identifier you targeted (slug or id) back before it proceeds. --yes (alias --confirm) skips that prompt and is required under --ci or without a TTY. The next render drops the vhosts, and the tenant falls back to the shared host and cookie selection. The checklist comes back empty, because there is nothing left to point DNS at. Removing the now-dead records is yours to do.

Data migration is explicitly out of scope

Switching a tenant between shared and dedicated moves routing, not data.

Dedicating seeds deployment specs and renders hostnames. Un-dedicating deletes those specs and stops rendering them. Neither reads or writes a single row of your application's data. Whatever a dedicated backend wrote to its own storage stays there when the tenant goes back to shared, and the shared backend's data does not follow a tenant into a dedicated one.

This is a deliberate boundary: the platform does not know your schema, your tenancy discriminator, or whether your storage is even a database. Moving data is an application concern. Decide it before flipping the mode, not after.

Command reference ​

Switching mode ​

bash
pfy tenant update <slug|id> --project <project-slug> --deployment-mode <shared|dedicated> [--yes]
FlagNotes
--deployment-modeshared or dedicated. Validated client-side, so a typo never reaches the server.
--yes / -y (alias --confirm)Skips the typed confirmation that --deployment-mode shared asks for, since un-dedicating deletes deployment rows. Required under --ci or without a TTY.
--projectNeeded to resolve a tenant slug to its ID.

Combinable with --name / --description in one invocation. Prints the DNS checklist on success. Capture it: it is the actionable half of the operation.

The GraphQL mutation behind it is setTenantDeploymentMode(tenantId, mode, confirm), which returns the checklist as [DnsChecklistEntry] (hostname, recordHint).

This action requires system administrator, or the maintainer or owner role on the owning project. Bare project membership is not enough. Un-dedicating permanently deletes deployment specs, so it sits at the same bar as creating and deleting them.

A delegated tenant administrator may not dedicate their own tenant, even with tenant-lifecycle permission. Deployment topology is an infrastructure and cost decision with DNS consequences, not a tenant-level setting. Every dedicate and un-dedicate is audited.

Targeting a dedicated deployment ​

Once a tenant is dedicated, its application has more than one deployment per environment: the shared row plus one per dedicated tenant. Every spec-consuming command takes --tenant to pick which:

bash
pfy deployment get    --env prod                    # the SHARED deployment
pfy deployment get    --env prod --tenant acme      # acme's DEDICATED deployment
pfy deployment set    --env prod --tenant acme --field resources.memory_mb=8192
pfy deployment snapshot --env prod --tenant acme    # -> deploy/spec.prod.acme.yaml
pfy nomad render      --env prod --tenant acme --tag v1.2.3
FlagMeaning
(omitted)the shared deployment; the pre-existing behaviour, unchanged
--tenant <slug>that tenant's dedicated deployment
--deployment-id <id>a specific row; combined with --tenant, the CLI verifies they agree rather than silently preferring one

Two details:

  • An --env alias in project.yaml caches the shared deployment it was created against. --tenant bypasses that cache, or it would silently ignore you.
  • The upstream (operations--acme) is server-derived. The CLI never computes it, so job, group, service and task names all inherit it without a second implementation that could drift.

See also ​