Framework guides

Elixir Phoenix 1.8 custom domains guide

Elixir Phoenix 1.8 · Elixir 1.20
Versions

Link to this edition

Example framework and runtime versions

Elixir
1.20.4
Erlang/OTP
29.0.6
Phoenix
1.8.12
LiveView
1.2.11

Your Phoenix app might host websites, documentation, or other pages for your customers. Custom domains let a customer put those pages at an address they own, while your app keeps handling the content.

This guide walks through adding that feature to an existing Phoenix app. We’ll start by making two local addresses show different customers’ pages. Then we’ll save customer domains, connect DNS and HTTPS, and serve the right pages for each hostname.

You’ll build on your existing customer accounts and login. The examples keep the code small so you can adapt each step to your app, with the networking concepts explained along the way.

In this guide

1. Match a hostname to a tenant locally

Every HTTP request includes a Host header such as alpha.localhost. Phoenix normalizes that into conn.host. Start with a development-only map so one endpoint can serve two tenants without any DNS or certificate work.

The examples call a customer’s account a Tenant. Use your existing account, workspace, or organization model wherever you see that name.

# lib/my_app_web/plugs/local_tenant.ex
defmodule MyAppWeb.Plugs.LocalTenant do
  import Plug.Conn, only: [assign: 3]
  alias MyApp.Accounts
  alias MyApp.Accounts.Tenant

  def init(opts), do: opts

  def call(conn, _opts) do
    local_hosts = %{
      "alpha.localhost" => "alpha",
      "bravo.localhost" => "bravo"
    }

    with {:ok, slug} <- Map.fetch(local_hosts, conn.host),
         %Tenant{} = tenant <- Accounts.get_tenant_by_slug(slug) do
      assign(conn, :current_tenant, tenant)
    else
      _ ->
        raise Phoenix.Router.NoRouteError,
          conn: conn,
          router: MyAppWeb.Router
    end
  end
end

Wire it to a development-only public scope. The controller uses your existing tenant template and assigns; it does not create a second content model.

# lib/my_app_web/router.ex
pipeline :local_tenant do
  plug MyAppWeb.Plugs.LocalTenant
end

scope "/", MyAppWeb do
  pipe_through [:browser, :local_tenant]
  get "/", CustomerPageController, :show
end

# inside MyAppWeb.CustomerPageController
def show(conn, _params) do
  render(conn, :show, tenant: conn.assigns.current_tenant)
end

This Host lookup selects a public page; it does not authorize account changes.

curl -H 'Host: alpha.localhost' http://127.0.0.1:4000/
curl -H 'Host: bravo.localhost' http://127.0.0.1:4000/
curl -i -H 'Host: unknown.localhost' http://127.0.0.1:4000/

Checkpoint: the first two requests show different tenants, and the unknown hostname returns 404.

2. Save each tenant's domains

Replace the map with an app-owned table. A globally unique normalized hostname prevents two tenants from reserving the same name. A small local state keeps pending and removing domains out of public routing.

# lib/my_app/domains/domain.ex
schema "domains" do
  field :hostname, :string
  field :state, Ecto.Enum,
    values: [:pending, :active, :removing],
    default: :pending
  belongs_to :tenant, MyApp.Accounts.Tenant
  timestamps(type: :utc_datetime)
end

Add a unique database index on hostname. Normalize before insert: trim spaces, remove one trailing dot, lowercase, then reject schemes, ports, paths, wildcards, literal IP addresses, empty labels, and invalid DNS label lengths.

Build the struct with the tenant from the signed-in session. Do not cast tenant_id from browser parameters.

def reserve_domain(%Tenant{id: tenant_id}, attrs) do
  %Domain{tenant_id: tenant_id}
  |> Domain.create_changeset(attrs)
  |> Repo.insert()
end

Keep this action in Phoenix's :browser pipeline so :protect_from_forgery checks the CSRF token. If you use LiveView, assign the authenticated tenant in on_mount and pass that tenant to every context call.

Checkpoint: a signed-in tenant can reserve a normalized hostname, while duplicates and cross-tenant requests are rejected.

3. Connect DNS and HTTPS

A reverse proxy is a server in front of Phoenix that receives requests and forwards them to your app. Your hosting platform may provide one, or you can run one yourself. Register the customer hostname there first, then show the customer the DNS record that points to it.

For a subdomain such as portal.example.com, a CNAME can point to a hostname owned by your receiving service.

An apex, or root domain, is the shorter example.com. Offer a stable IPv4 address and an A record for apex domains because many DNS providers cannot place a normal CNAME there and do not support CNAME flattening. You can offer an AAAA record as an additional IPv6 route when your service supports it.

  1. Add the exact customer hostname to the server, load balancer, or hosting platform that will receive it.
  2. Show the matching CNAME for subdomains or stable IPv4 A record for apex domains.
  3. Have the customer copy that record into the DNS provider that manages their domain.
  4. Issue a valid HTTPS certificate for the hostname and arrange automatic renewal.
  5. Forward the original Host header to Phoenix so the app can select the tenant.

You’ll probably want to check that DNS points directly or indirectly to the service receiving traffic for your app. Tools and managed services can make DNS setup and HTTPS much easier; see the bonus section below for one option.

DNS routes the request to your receiving service. HTTPS is negotiated before Phoenix sees the request, so certificate issuance and renewal belong at that service. Mark the saved domain active only after the hostname is connected there and HTTPS is ready.

Checkpoint: the hostname reaches your receiving service over HTTPS and arrives at Phoenix as the original Host.

4. Serve the tenant's pages

Replace the local map with one context query. Only an active row can select content. Unknown, pending, and removing hostnames return 404.

def route_by_hostname(hostname) do
  with {:ok, normalized} <- Domain.normalize_hostname(hostname),
       %Domain{} = domain <-
         Repo.one(
           from d in Domain,
             where:
               d.hostname == ^normalized and d.state == :active,
             preload: [:tenant]
         ) do
    {:ok, domain}
  else
    _ -> {:error, :not_found}
  end
end

Before existing authentication or management routes run, reject every hostname except your configured app hostname. Put the reusable plug before your authenticated pipeline:

# config/runtime.exs
config :my_app, :control_host, System.fetch_env!("CONTROL_HOST")

# lib/my_app_web/plugs/require_control_host.ex
defmodule MyAppWeb.Plugs.RequireControlHost do
  import Plug.Conn, only: [halt: 1, send_resp: 3]

  def init(opts), do: opts

  def call(conn, _opts) do
    if conn.host == Application.fetch_env!(:my_app, :control_host) do
      conn
    else
      conn
      |> send_resp(404, "Not found")
      |> halt()
    end
  end
end

# lib/my_app_web/router.ex
pipeline :control_host do
  plug MyAppWeb.Plugs.RequireControlHost
end

scope "/", MyAppWeb do
  pipe_through [:browser, :control_host, :require_authenticated_user]
  # Keep your existing management routes here.
end

Store CONTROL_HOST as a lowercase hostname without a scheme or port. Then use the domain query in the separate public scope's plug and assign the domain and tenant:

def call(conn, _opts) do
  case Domains.route_by_hostname(conn.host) do
    {:ok, domain} ->
      conn
      |> put_resp_header("cache-control", "private, no-store")
      |> put_resp_header("vary", "Host")
      |> assign(:current_domain, domain)
      |> assign(:current_tenant, domain.tenant)

    {:error, :not_found} ->
      raise Phoenix.Router.NoRouteError,
        conn: conn,
        router: MyAppWeb.Router
  end
end

Read the direct Host by default. Trust a forwarded hostname only when a known proxy overwrites it and direct access is blocked or authenticated. A client header must never grant tenant management access.

Host-only session cookies keep control credentials off customer domains. Use a trusted app URL for dashboard links. If you cache public content, key it by hostname or tenant; the conservative headers above avoid cross-tenant cache reuse.

Checkpoint: active hostnames render the right tenant, the normal app hostname still serves management, and every other hostname returns 404.

5. Remove a domain safely

Stop routing before asking the receiving infrastructure to detach the hostname and certificate. Keep the unique reservation after a timeout or unclear error so another tenant cannot claim it too early.

def start_removal(%Domain{} = domain) do
  query =
    from d in Domain,
      where:
        d.id == ^domain.id and d.tenant_id == ^domain.tenant_id and
          d.hostname == ^domain.hostname and
          d.state in [:pending, :active, :removing]

  case Repo.update_all(query, set: [state: :removing]) do
    {1, _} ->
      {:ok, %{id: domain.id, tenant_id: domain.tenant_id,
              hostname: domain.hostname}}
    _ ->
      {:error, :domain_changed}
  end
end

The same function returns the saved identity when the row is already removing, so a retry does not create a new claim. Pass that identity to your hosting cleanup adapter. Delete the row only after the system confirms the hostname and certificate are gone, and only while ID, tenant, hostname, and :removing still match.

A delayed readiness check should update only the same saved domain while its state still matches. That prevents an old result from changing :removing back to :active.

Checkpoint: removal immediately returns 404, retries keep the reservation, and confirmed infrastructure cleanup releases it.

Bonus: let Approximated handle HTTPS and forwarding

The five steps above work with any hosting setup. Approximated can accept each customer hostname, issue and renew its certificate, forward traffic to your app server, and return the DNS instructions for your control page.

Your app still owns tenant authorization, hostname reservation, content lookup, and activation. In the API, a virtual host is the mapping from a customer hostname to your reachable app-server hostname.

Keep the API key in runtime server configuration. For this optional adapter, add a nullable provider_id field to the domain schema. After the hostname is reserved, send POST /api/vhosts with keep_host: true. Attach the result only when it contains a positive numeric ID and exactly the hostname you requested.

client = Req.new(
  base_url: "https://cloud.approximated.app",
  headers: %{"api-key" => System.fetch_env!("APX_API_KEY")},
  receive_timeout: 5_000,
  retry: false
)

attrs = %{
  incoming_address: domain.hostname,
  target_address: System.fetch_env!("APX_ORIGIN_HOST"),
  target_ports: "443",
  keep_host: true
}

with {:ok, %{status: 201, body: %{"data" => data}}} <-
       Req.post(client, url: "/api/vhosts", json: attrs),
     %{"id" => id, "incoming_address" => hostname} <- data,
     true <- is_integer(id) and id > 0,
     true <- hostname == domain.hostname do
  query =
    from d in Domain,
      where:
        d.id == ^domain.id and d.tenant_id == ^domain.tenant_id and
          d.hostname == ^domain.hostname and d.state == :pending and
          is_nil(d.provider_id)

  case Repo.update_all(query, set: [provider_id: id]) do
    {1, _} -> {:ok, id}
    _ -> {:error, :domain_changed}
  end
else
  _ -> {:error, :creation_not_confirmed}
end

If creation is unclear, keep the tenant's reservation but do not search for, adopt, or delete a remote resource by hostname.

Treat missing status as pending, missing status_message as empty, and missing monitor fields as unknown. Reject a present status field unless it is text, and reject a present monitor unless it is Boolean or null. Show these API values in plain language:

  • user_message: exact DNS instructions, displayed as plain text
  • is_resolving: the hostname returns an HTTP response somewhere
  • apx_hit: traffic reaches Approximated
  • has_ssl: HTTPS is ready

Fetch GET /api/vhosts/{id} with the confirmed ID you stored. The response reports Approximated’s stored automatic-check results rather than starting a fresh check. Activate only when it still contains the same ID and hostname, apx_hit is true, and has_ssl is true. Keep is_resolving as a diagnostic because an HTTP response may come from somewhere other than Approximated.

For removal, change the local state before DELETE /api/vhosts/{id}. Keep the reservation after an error or accepted deletion, retry the same ID, and release only after GET /api/vhosts/{id} returns 404. A delayed check should update only the same tenant, hostname, provider ID, and expected state.

Checkpoint: the control page shows the returned DNS instructions, and the confirmed mapping becomes active only after Approximated reports connection and HTTPS ready.

Run the complete Phoenix example

The Phoenix custom-domains example includes two tenants, SQLite storage, Phoenix sessions and CSRF, a local provider stub, readiness checks, and confirmed removal.

git clone https://github.com/Approximated-Inc/phoenix-custom-domains-example.git
cd phoenix-custom-domains-example
mix setup
PORT=4005 mix phx.server

The native development endpoint binds to loopback. Open http://localhost:4005/dev/login for the fixture control page, or request alpha.localhost and bravo.localhost to compare the public pages.

References