Multi-Tenant Apps & Dynamic Domains

Building a product where your customers get their own hostnames, like a website builder, a hosted storefront, or a status-page service? Multi-tenant mode lets one Phoenix app serve unlimited hostnames with automatic HTTPS.

Multi-tenant mode is available on the Solo plan and above.

Two levels of integration

Tenant subdomains only (steve.yourapp.com, sue.yourapp.com): a single wildcard certificate covers every subdomain.

Tenant custom domains (example.com): certificates are minted on demand, the first time each hostname receives an HTTPS request. Enable this level only when your product offers "connect your own domain".

You can start with subdomains only and switch custom domains on later - or off again. Everything else stays the same, including your wildcard subdomains.

1. Enable multi-tenant mode

In your app's Domains tab, find the Multi-tenant mode card and click Enable. Choose the level you need:

  • Tenant subdomains only: no prerequisites.
  • Subdomains + tenant custom domains: implement the permission endpoint (section 4) first, since it becomes the gate for certificate issuance once active.

2. Set up tenant subdomains

When using tenant subdomains, new tenants work instantly. There is no per-tenant issuance, no rate limits, and no tenant names leaking into public certificate logs.

To set this up for your app:

  1. In the Multi-tenant mode card, click Add Wildcard Domain and enter *.yourapp.com.
  2. Potions shows you two DNS records to add at your DNS host: the wildcard A record pointing at your server IP, and a one-time _acme-challenge CNAME. Create both records, wait for them to propagate, and then click Verify DNS.

3. Configure Phoenix for many hostnames

Needed at both levels:

check_origin: LiveView and channels verify the WebSocket origin against your endpoint's configured host, which breaks for tenant hostnames. Set it to :conn so the origin is checked against whatever host the request came in on:

# runtime.exs
config :my_app, MyAppWeb.Endpoint,
  check_origin: :conn

PHX_HOST: stays pointed at your .onpotions.com platform subdomain - or custom domain. Potions doesn't rewrite it for multi-tenant apps. Generate tenant-facing URLs from the request's host rather than the endpoint config.

Routing by hostname: look up the tenant from conn.host, with a plug or :host matching in your router. Unknown subdomains reach your app with valid TLS too (the wildcard covers them), so we recommend rendering a "no such site" page for them.

Your base domain: your app needs to know which domain tenant subdomains use, so it can split steve.yourapp.com into a tenant name. This is done by defining your own environment variable - e.g. TENANT_BASE_DOMAIN - in the Environment tab and reading it in runtime.exs.

4. Set up tenant custom domains

This step is required if your app offers "connect your own domain".

When a hostname Caddy has never seen arrives, Caddy holds the TLS handshake and asks your app whether to mint a certificate:

GET /__potions/domain-check?domain=<hostname>
  • Respond 200 if the hostname belongs to one of your tenants: the certificate is issued and the request is served.
  • Respond 403 (or anything that isn't 2xx) for hostnames you don't recognize: the TLS handshake fails.

Your app already knows which tenant hostnames exist, so this endpoint is just a lookup against that data. It's also mandatory: without it, anyone could point a domain at your server and consume certificate issuance on your behalf.

Add a route and controller to your app. Keep it fast: the TLS handshake for a new hostname waits on this response.

Here's an example of how to handle this:

# router.ex
scope "/__potions", MyAppWeb do
  pipe_through :api

  get "/domain-check", DomainCheckController, :show
end
# domain_check_controller.ex
defmodule MyAppWeb.DomainCheckController do
  use MyAppWeb, :controller

  def show(conn, %{"domain" => domain}) do
    if MyApp.Tenants.active_hostname?(domain) do
      send_resp(conn, 200, "ok")
    else
      send_resp(conn, 403, "denied")
    end
  end

  def show(conn, _params), do: send_resp(conn, 400, "missing domain")
end

active_hostname?/1 should return true for every hostname you serve. Requests come from Caddy over localhost, so the endpoint needs no authentication.

Tip: approve a custom domain after the tenant has confirmed they've pointed DNS at your server. If you approve earlier, certificate issuance fails DNS validation and Let's Encrypt temporarily rate-limits retries for that hostname (5 failures per hour).

Your end-users connect custom domains with two simple record shapes:

Record Type Name Value
A (apex domains, e.g. example.com) @ Your server's IP address
CNAME (subdomains, e.g. blog.example.com) blog The tenant's subdomain, e.g. alice.yourapp.com

Choosing between them:

  • CNAMEs aren't allowed at an apex, so apex domains need the A record.
  • For subdomains, CNAME is preferred: it keeps working if your server's IP ever changes. A plain A record to the IP works too.
  • Don't have tenants CNAME to your .onpotions.com platform subdomain: it's proxied through Cloudflare and certificates won't be issued.

Things to Know

  • Custom-domain certificates are issued on the first request: expect a few-second delay on a new domain's first HTTPS request while its certificate is minted. Every request after that is full speed.
  • Renewals are automatic. On-demand certificates renew as long as the hostname keeps receiving traffic; abandoned hostnames' certificates simply expire and are cleaned up.
  • If your app is down (custom-domains level only), existing certificates keep working, but new hostnames can't be approved until it's back.
  • One app per server: multi-tenant mode is server-wide, so only one app per server can run it. Other apps on the server, and your own named domains, work as usual alongside it.