Shared vs. dedicated tenants
Every tenant is shared by default: one backend per application serves all of them, and the tenant a request belongs to is resolved per request 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 rather than 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: they disappear from its tenant chooser, and a cookie naming them 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
| Shared | Dedicated | |
|---|---|---|
| Backends per app | one, serving all tenants | one per dedicated tenant, plus the shared one |
| Hostname | app.<base_domain> | app.<tenant>.<base_domain> |
| Upstream | app | app--tenant |
| Tenant resolution | signed cookie, re-validated per request | pinned in the vhost; the cookie is never read |
| Tenant switching | /_tenant chooser | nothing to choose — the host is the tenant |
| On the shared host | selectable | not offered, and not servable, once their own host is rendered |
| Onboarding a tenant | Manager rows only | Manager rows plus a DNS record |
| Cost | one backend | one backend per dedicated tenant |
| Blast radius | shared | isolated |
When to use which
Stay shared unless something below forces your hand. Shared is cheaper, has no DNS story, and onboarding a tenant is a database row.
Go dedicated when:
- Isolation is contractual. A customer requires that their traffic never share a process with another tenant's — a compliance answer, not a technical one, and 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 with it.
- 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, ahead of everyone else.
- A tenant needs their own vanity domain. Explicit
domainswork in both modes, but the dedicated row is the natural place to hang them.
Do not go dedicated merely for per-tenant configuration or branding. Those are settings-cascade concerns (project → tenant → tenant-application) and work identically on a shared backend, at none of the cost.
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. This catches people precisely because 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.
Because none of this can be provisioned for you, dedicating a tenant returns a DNS checklist rather than pretending it is automatic:
$ 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:
operations.acme.apps.example.com {
route /* {
productify_protect app-ops with pocketpolicy {
tenant 11111111-1111-1111-1111-111111111111
}
reverse_proxy operations--acme:8080
}
}Three properties are worth stating explicitly, because each is a security claim:
- The cookie is never consulted. A stale, forged or simply foreign selection cookie has no effect on a dedicated host — not "is rejected", but "is not read". A user who legitimately selected another tenant on the shared host is still served the pinned tenant here.
- 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. Unlike the cookie path — which fails open on purpose, so a Manager blip does not log everyone out of a selection they legitimately made — an unverifiable membership here denies. A non-member gets
403, never a redirect to the chooser (on a pinned host that would only loop). - 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, which 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:
| Request | Answer |
|---|---|
GET /_tenant | catalog of one: the pinned tenant, already current |
POST /_tenant | 409 — nothing to switch to (not a 204 that silently changes nothing) |
GET /_tenant/select | 303 to / — an empty chooser is a dead end |
/_tenant/logout | unchanged, 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 merely given an additional one. Once their own hostname is being served, on app.<base_domain> that tenant:
- no longer appears in the
/_tenantcatalog, so the chooser does not offer it; - cannot be selected by naming it explicitly — a cookie or a
POST /_tenantnaming that tenant is refused.
Why 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 remained 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 the part worth understanding, because it is what makes lockout impossible. A tenant is excluded from the shared host only where a pinned vhost demonstrably exists — meaning all three of:
- the tenant is in
dedicatedmode, and - the application is enabled for it, and
- the deployment derives at least one hostname (so the environment has a
base_domain, or the row lists explicitdomains).
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 them off the shared host. They keep working there until somewhere to move them to has genuinely been rendered. A tenant is never removed from the host that serves them until another one does.
Known limitation: the rule is per-application, not per-environment
The exclusion ideally applies 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 them a pinned one. The alternative — excluding as soon as any environment has a pinned vhost — would lock them out of the shared host of an environment where they have 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, so a dedicated tenant can be held at a specific pilet version while everyone else moves on — 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, resolved through the ordinary settings cascade:
{
"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
enabledswitch — which is how a bad build gets withdrawn. - A pin naming a missing or disabled version is ignored with a warning, never an error. Pins are hand-written, typos are expected, and failing the feed would blank the application for every user of that tenant.
- 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; pins are written through the existing settings surfaces.
Un-dedicating, and the data question
pfy tenant update acme --project retail --deployment-mode shared --confirm--confirm is required because this deletes the tenant's dedicated deployment rows. The next render drops the vhosts, and the tenant falls back to the shared host and cookie selection. The checklist comes back empty — there is nothing left to point DNS at — and 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
pfy tenant update <slug|id> --project <project-slug> --deployment-mode <shared|dedicated> [--confirm]| Flag | Notes |
|---|---|
--deployment-mode | shared or dedicated. Validated client-side, so a typo never reaches the server. |
--confirm | Required for --deployment-mode shared: un-dedicating deletes deployment rows. |
--project | Needed 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.
This action requires system administrator, or the maintainer or owner role on the owning project. Bare project membership is not sufficient — un-dedicating permanently deletes deployment specs, so it sits at the same bar as creating and deleting them rather than below it.
A delegated tenant administrator may not dedicate their own tenant, even holding 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 therefore takes --tenant to pick which:
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| Flag | Meaning |
|---|---|
| (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 worth knowing:
- An
--envalias inproject.yamlcaches the shared deployment it was created against.--tenanttherefore 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
- Multi-tenancy — the shared model and tenant switching
- Configuration layers — where
pilets.pinslives - Deployment architecture — proxy topology and vhost rendering
pfy tenant— CLI reference