Skip to main content

Multi-tenancy

One Kiln deployment can serve many sites. Each site is an organization (KilnCMS.Accounts.Organization), and an org's content, media, taxonomy, forms, analytics, newsletter and history are isolated from every other org's. A single-site install is just a deployment with one org — the default org — and never has to think about any of this.

This page is the isolation model in one place. The configuration table is in environment-variables.md; who can do what within an org is in granular-rbac.md.

The model: one database, rows tagged by org

Kiln uses Ash attribute multitenancy: every per-site table carries an org_id column, and every read and write on it is filtered by the request's org. It is not schema-per-tenant — one set of tables, one set of indexes, one migration run — because schema-per-tenant would multiply the pgvector and trigram search indexes by the number of sites.

The data layer fails closed. config :kiln_cms, :strict_tenancy (on by default, compile-time) makes every org-scoped resource require a tenant (global?: false), so an action that forgets to pass one is an error rather than a read across every org. The few reads that are deliberately org-spanning — resolving a newsletter confirm/unsubscribe token and billing webhooks, which arrive knowing a token or a payment id rather than a host — are marked multitenancy :bypass action by action, and then scope themselves to the org of the row they found.

How a request finds its org

KilnCMSWeb.Tenant.fetch_org/1 resolves the org from the request's host, in this order:

  1. a subdomain of TENANT_BASE_HOST (default: PHX_HOST) — acme.example.com → the org whose slug is acme;
  2. an exact custom_domain on an org — www.acme.com;
  3. otherwise the default org.

KilnCMSWeb.Plugs.SetTenant runs this in the endpoint, before the router, and sets the result as the Ash tenant — so the JSON:API and GraphQL surfaces are scoped with no per-resolver code. LiveView mounts and all three sockets (/ws/gql, /ws/bridge, /ws/collab) resolve the same way from the URI they connected on, never from anything the client sends in its payload.

Lookups are cached (KilnCMS.Cache.Hosts), so resolution is not a query per request.

The default-org fallback, and TENANT_STRICT_HOST

Step 3 is right for a single-host install — a bare hostname, an IP literal or localhost should reach the only site there is. On a multi-tenant deployment it is wrong: any request with an unrecognized Host, including an attacker-supplied one, would be served the default org's content, branding and analytics.

Strict host matching turns itself on once a second organization exists (#1547). With TENANT_STRICT_HOST unset — the default — a single-org install keeps the fallback, and the create that makes a deployment multi-tenant removes it: an unmatched host then gets a 404 (or a 503 with retry-after if the lookup could not run because the database is down) instead of the default org. No restart, and every node follows — the creating node tells the others over Phoenix.PubSub, and a node that missed the message recounts within 30 seconds (#1654). The apex (PHX_HOST) is never refused; health probes and the payment webhook are exempt. The full behaviour, including what static files do, is under TENANT_STRICT_HOST.

TENANT_STRICT_HOST=true refuses unknown hosts even with one org. TENANT_STRICT_HOST=false keeps the fallback only while there is one org (#1662). Until 0.12 it kept it with many too, which served an unrecognized — and possibly attacker-chosen — Host another tenant's site; Kiln no longer honours false on a multi-org deployment. Once a second organization exists, unknown hosts are refused whatever the setting says, and Kiln logs an error saying the setting is being ignored: at boot, when the second org is created, and on /editor/system for as long as false stays set. Remove it.

There is deliberately no switch to get the old behaviour back. Anything that relied on the fallback can be given a real home instead: a host that should reach a site belongs to an organization — a subdomain of TENANT_BASE_HOST, or its custom_domain (the default org can have one too) — and anything else, a second alias or a bare IP, is redirected to one of those at your proxy. The PHX_HOST apex and KILN_CONSOLE_HOST are never refused, and the health probes and the payment webhook are exempt. Organizations cannot be deleted, so a single-org install that creates a second one — even by accident — stays strict; check your hosts before you create it.

If the organizations cannot be counted — a node that booted while Postgres was unreachable — an unset setting behaves as multi-org and refuses unknown hosts until the count succeeds. Serving an unrecognized host another tenant's site cannot be undone; a retryable refusal on a single-org install for the length of an outage can. See KilnCMSWeb.Tenant.OrgCount.

What is per-org and what is deployment-wide

Per org (carries org_id): every content type and its versions, published artifacts and their reference graph, media, categories and tags, dynamic content types and custom fields, forms and submissions, webhooks and their deliveries, analytics, funnels and experiments, newsletter subscribers and sends, automation rules, document history, custom roles, and the org's settings (branding, public theme, editorial settings). Cache keys for these include the org id, and search results are filtered to the request's org.

Deployment-wide (no org_id): user accounts and their sign-in methods (passwords, passkeys, SSO identities, tokens), API keys, outbound mail settings (sender and DKIM key — one per deployment), billing settings, the mail suppression list, and federation replay protection.

So one person has one account across sites and belongs to orgs through KilnCMS.Accounts.OrgMembership, which carries their tier (:admin / :editor / :viewer) and editorial scope for that org. An account that holds any membership has no access to an org it isn't a member of — the org comes from the host, which the client controls, so falling back to the account's global role there would let a scoped editor escape by switching hosts. A platform admin (User.role == :admin) is the exception and keeps admin everywhere. Details: granular-rbac.md, "Per-org capability tiers".

API keys belong to a user, not an org. A headless request is scoped to the org its host resolves to, and the key's user is authorized there like any other actor.

Creating an org

Creating and updating orgs is a platform-admin action (KilnCMS.Accounts.create_organization/2 with an admin actor); there is no screen for it yet. An org has a name, a slug (its subdomain) and an optional custom_domain. The slug also names its console host when KILN_CONSOLE_HOST is set (<slug>.<console host>, below). Members are then managed per org at /editor/team.

The slug is a hostname label, because it becomes one (#1710). It is trimmed and downcased on save (Acme is stored as acme), and then refused unless it is 1 to 63 of a-z, 0-9 and -, not starting or ending with -. www, console, api and mail are reserved, and so is the first label of KILN_CONSOLE_HOST when that host sits directly under PHX_HOST. A slug saved by an earlier release had no such rule. mix kiln.org_slugs lists the orgs whose slug can't be a hostname, and --fix downcases the ones where that alone is enough. In a release, run bin/kiln_cms eval 'KilnCMS.Release.org_slugs()'. Kiln also warns at boot while any are left.

Creating the second org turns strict host matching on unless TENANT_STRICT_HOST is already true — and since 0.12 an explicit false does not stop it (see above) — so before you create it, make sure every host the deployment answers on is either PHX_HOST or belongs to an org.

With KILN_CONSOLE_HOST unset, it is also the create that puts one org admin's code injection on the same origin as every other tenant's editors (#1661) — see Serving the console from its own host. Kiln warns at boot, on that create, and on /editor/system.

It also caps form embedding at EMBED_ORIGINS unless EMBED_ORIGINS_LOCKED is set (#1618): from then on an org admin's per-form or per-site embed allowlist may only narrow the operator's list, and a saved list naming a site outside it stops being served to that site. With EMBED_ORIGINS unset, that means no other site can frame any form — so before you create the second org, put every site any org embeds forms on into EMBED_ORIGINS, or set EMBED_ORIGINS_LOCKED=false to keep each org's own list uncapped. Kiln warns at boot, on that create, and on /editor/system while saved lists are being cut down. Details: forms.md.

Orgs cannot be deleted: paper-trail version rows outlive the content they describe, and deleting an org would strand them. A single-tenant install that wants a hard guarantee can set config :kiln_cms, :multitenancy_enabled, false, which refuses any org beyond the default one.

Serving the console from its own host

By default each org's editor console answers on that org's own host, so a page's custom scripts are same-origin with the console. KILN_CONSOLE_HOST moves every console onto hosts that serve no tenant content:

OrgSite (code injection runs here)Console
defaultexample.comconsole.example.com
acmeacme.example.com (or its custom_domain)acme.console.example.com
betabeta.example.combeta.console.example.com

Each org's console host is derived from its slug (#1688), so there is nothing to configure per org. An editor who opens /editor on an org's site is redirected to that org's console host. Every console host is its own origin with its own session cookie, so no site's script can reach any console, and no org's console shares an origin with another org's. Signing in on one console host does not sign you in on another; each is a separate sign-in.

Setting it up:

  1. Pick a host under PHX_HOST, e.g. console.example.com for PHX_HOST=example.com. Passkeys' relying-party ID is the PHX_HOST host, and a browser accepts it only on hosts under it. A console host outside PHX_HOST still works, but without passkey sign-in, and Kiln warns about that at boot. Existing passkeys keep working; nobody re-enrolls.
  2. DNS and TLS for the host and its wildcard: console.example.com and *.console.example.com. A certificate for *.example.com does not cover acme.console.example.com.
  3. Set KILN_CONSOLE_HOST=console.example.com and restart. Kiln adds the console hosts to the socket origin check itself; CHECK_ORIGINS needs no change.

On a single-org deployment only the bare console host is ever used, so the wildcard is optional there.

On a multi-org deployment without it, an org admin's code injection can act with the session of any editor who opens that org's site signed in, a platform admin included. Kiln warns about exactly that combination (more than one org and KILN_CONSOLE_HOST unset) at boot, when the second org is created, and on /editor/system (#1661, threat model residual risk 16). The alternative is to grant org admin only to people you would trust with the console. Why one console host per org rather than one shared, org-switching console host is decision record 0011. See environment-variables.md and code-injection.md.