Tutorial

Elixir Phoenix Custom Domains Guide

A tutorial guide for supporting user custom domains as a feature in your Elixir Phoenix app, with an example github repo.

This guide is meant to show you how to allow users of your Phoenix app to connect their own domains and load up content specific to them. Custom domains - sometimes called vanity domains, whitelabel domains, or customer domains - are a key feature of many apps.

If you need to manage custom domains with SSL for SAAS, marketplaces, blog hosts, or other platforms, then this is the guide for you.

What you'll learn in this guide:

  • Domains. We'll briefly go over web domains, how they work, and how they can be used as custom domains with your Phoenix app.
  • Networking. For your Phoenix app to handle custom domain requests, they first need to reach your server(s).
  • Routing. Once the request reaches your Phoenix app, you'll likely want to route it differently than other requests.
  • Plugs & Hooks. In Phoenix, plugs and liveview hooks can help authorize or filter out requests for custom domains.
  • Controllers & Liveviews. Returning the right responses and content for custom domains, whether they're dead views, liveviews, or API responses.
  • Bonus! How to automate the networking and SSL management easily with Approximated.

Web domains and how they work

You're likely pretty familiar with web domains, but we'll go over a few of the specifics of how domains work that can sometimes cause confusion.

Registrars and ownership

Domains are bought and managed with registrars like Hover, GoDaddy, Namecheap, and many others. This is where a domain is officialy registered and may or may not be where it's DNS is managed. For our purposes, we can assume that it will be the user that owns their own domain, which they would like to point at your app.

DNS and pointing domains

Domains don't do much unless they're pointed at a target server. This is done with DNS, which tells devices where to send requests for a domain, using DNS records. These records are set at the domain's DNS provider, which may or may not be the same as the registrar.

Apex Domains vs Subdomains

Apex domains are the root, naked domain with no subdomain attached. For instance, example.com is an apex domain.

A subdomain is what we call it when we prepend something to an apex domain. For example, sub.example.com or sub2.sub1.example.com are both subdomains.

Technically, subdomains are domains. For the purpose of this guide though, when we use the word domain, we're referring to an apex domain.

SSL, TLS, and encrypting requests

When a request is sent out over the internet, there are various points where it's contents could be inspected and tampered with unless they're encrypted.

This is especially important for any private data such as ecommerce or personal information, and may be legally required in many cases. It's generally a very good idea for all requests to be encrypted, in order to prevent censorship, request injections, snooping and other tampering.

SSL certificates - which are technically TLS certificates now, but often still called by the old name - are the standard by which browsers and other applications encrypt these requests. Using them requires a server with a provisioned SSL certificate for each domain. We'll go more into detail on this in the networking section below.

Networking and SSL for custom domains on Phoenix

In order for your app to serve custom domains, the requests for each custom domain need to be networked to reach your server(s). For those requests to be secured, you'll also need to provide and manage an SSL certificate for each custom domain.

Networking - getting the request to your server

Each custom domain will need to have DNS records set to point requests at your app - either directly or indirectly. There are two options for how you can go about this, each with significant trade-offs that you should know about:

  1. DNS A records pointed at an IPv4 address

    DNS A records can only be pointed at an IPv4 address, formatted as x.x.x.x, where each x can be any value between 0 and 255.

    It's important to note that apex domains - those with no subdomain (including www) - can only be pointed with A records. They cannot be pointed with CNAME records.

    This is the easiest and most universal method but also carries the risk that if you ever need to change that IPv4 address, you'll need to have each user change their DNS individually.

  2. CNAME records pointed through an intermediate domain

    CNAME records cannot point an apex domain and cannot point at an IPv4 address, but they can point subdomains at any other domain or subdomain.

    For example, you could point subdomain.example.com at test.com and requests would be sent to wherever test.com is pointing.

    If it's acceptable in your use case to require custom domains have a subdomain, then you can do the following:

    • Point the A record for a subdomain that you own at your server IPv4 address.
    • When a custom domain is created, have your user point a CNAME record at the subdomain above.
    • Requests for the custom domain will reach wherever the intermediate subdomain is pointed.
    • This has the advantage of allowing you to re-point all of the custom domains at once, by changing the A record for the intermediate subdomain.
    • The disadvantage here is that those custom domains cannot be apex domains and must have a subdomain.

SSL Certificates - securing many custom domains

You've likely secured your app already with SSL certificates, but unfortunately those will only apply to requests going directly to that domain. SSL certificates cannot be shared amongst apex domains.

You're going to need SSL certificates for each individual custom domain that points at your app, to encrypt requests as well as ensure security and privacy for that domain specfically.

In order to do that you'll need an automated system to provision SSL certificates automatically when custom domains are created, including:

  • Requesting the generation of an SSL certificate from a Certificate Authority.
  • Accepting and appropriately completing the verification challenge from the Certficate Authority.
  • Renewing within a reasonable window before the certificate expiration date (typically 3 months).
  • Avoiding going over rate limits with Certificate Authorities.
  • Checking for certificates that have been revoked early by Certificate Authorities and replacing them.
  • Monitoring all of it to catch and avoid the many edge cases and unexpected issues that can cause customer facing downtime.

For those that absolutely need to build this in-house, we recommend Caddy as your starting point. However, this is a very complex topic and could easily be many guides worth of content without even covering everything.

Building SSL management and networking systems in-house for more than a few domains is extremely non-trivial even with the help of amazing tools like Caddy. The potential pitfalls are many and the risk of customer facing downtime or security vulnerabilities is reasonably high without considerable experience.

To keep this tutorial from exploding in length, we won't be covering building this in-house here. If you'd prefer to have a third party service handle this for you, please see our bonus section below.

Routing custom domains within a Phoenix app

Once requests for custom domains are able to reach your Phoenix app, we'll probably want to handle them separately from other routes within the app.

For instance, a blog platform likely wants to display the blog that custom domain is tied to instead of your app home page. This section will explain how you can do that.

Grouping routes for custom domains in the Phoenix routers

Most Phoenix apps are going to use the router located at /lib/myapp_web/router.ex, as you probably know. When a request from a browser comes through to your Phoenix app, it typically goes through a few plugs (more on these later) and then through that router.

Inside of the router, we can group routes together by using the router's host matching feature for our primary domain routes. Then we can use a plug or liveview hooks to authorize custom domain routes in a separate group below it.

Here's a complete example router using this strategy:

# in /lib/my_app_web/router.ex

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  import MyAppWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_user
  end

  # This is a duplicate of the browser pipeline above,
  # but without the plug setting the layout,
  # since we want to set that elsewhere.
  # This could be handled differently,
  # but we're keeping it simple for the example.
  pipeline :custom_domain_browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_user
  end

  pipeline :custom_domains do
    plug MyAppWeb.CustomDomainsPlug
  end

  # A scope to match requests against our primary domain.
  # The host (domain) must be set at compile time, not run time.
  scope "/", MyAppWeb, host: Application.compile_env(:my_app, :primary_domains, ["localhost"]) do
    # You can nest scopes, just be aware that any plugs/pipelines
    # set in a parent scope will apply to all child scopes.

    # Default phx.gen.auth routes
    scope "/" do
      pipe_through [:browser, :redirect_if_user_is_authenticated]

      live_session :redirect_if_user_is_authenticated,
        on_mount: [{MyAppWeb.UserAuth, :redirect_if_user_is_authenticated}] do
        live "/users/register", UserRegistrationLive, :new
        live "/users/log_in", UserLoginLive, :new
        live "/users/reset_password", UserForgotPasswordLive, :new
        live "/users/reset_password/:token", UserResetPasswordLive, :edit
      end

      post "/users/log_in", UserSessionController, :create
    end

    # The routes that should only be available to authenticated users on the primary domain.
    # These include user settings, CRUD routes for blogs.
    scope "/" do
      pipe_through [:browser, :require_authenticated_user]

      live_session :require_authenticated_user,
        on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}] do
        live "/users/settings", UserSettingsLive, :edit
        live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email

        live "/blogs", BlogLive.Index, :index
        live "/blogs/new", BlogLive.Index, :new
        live "/blogs/:id/edit", BlogLive.Index, :edit

        live "/blogs/:blog_id/posts/new", BlogPostLive.Index, :new
        live "/blogs/:blog_id/posts/:id/edit", BlogPostLive.Index, :edit

        live "/blogs/:blog_id/posts/:id/show/edit", BlogPostLive.Show, :edit
      end
    end

    # More default phx.gen.auth routes
    scope "/" do
      pipe_through [:browser]

      delete "/users/log_out", UserSessionController, :delete

      live_session :current_user,
        on_mount: [{MyAppWeb.UserAuth, :mount_current_user}] do
        live "/users/confirm/:token", UserConfirmationLive, :edit
        live "/users/confirm", UserConfirmationInstructionsLive, :new
      end
    end
  end

  # A catch-all scope for any other hosts (custom domains).
  # While all of the other scopes and routes are inside the
  # scope matching the host to the primary domain, we want this
  # to match ANY host, because those will be our custom domains.
  #
  # The :load_blog_for_custom_domain hook will 404 if it doesn't
  # find a matching blog, which is reasonable enough protection against
  # random domains pointing at the app, at least for this example.
  scope "/", MyAppWeb do
    pipe_through [
      # a duplicate of the :browser pipeline, minus the layout plug (set below)
      :custom_domain_browser,
      # a plug that checks for a header or a hostname other than the primary, and
      # sets it in the session as the custom domain.
      :custom_domains
    ]

    live_session :custom_domain_blog, [
      # assigns the custom domain and blog struct to every liveview in this block
      on_mount: [{MyAppWeb.CustomDomainLiveviewHooks, :load_blog_for_custom_domain}],
      # sets the layout to one dedicated to loading the blogs on custom domains
      # Layout module located in my_app_web/components/layouts.ex below the default module
      layout: {MyAppWeb.CustomDomainLayouts, :root}
    ] do
      # The blog index/home page listing posts
      live "/", CustomDomainBlogLive, :index
      # An individual post loaded by post slug
      live "/:post_slug", CustomDomainBlogPostLive, :index
    end
  end
end

If you have existing routes, you probably want them in the primary domain scope. Depending on your app you may want to get more sophisticated than this, but this is a good place to start.

With this approach, you can cleanly separate out which routes are available to custom domains and which are available to your primary domain. It also means that you can use the same route paths but do different things for custom domains vs your primary domain.

Using plugs or liveview hooks with custom domains in Phoenix

When a request comes in to Phoenix it goes through a series of plugs that each potentially modify the request or take some kind of action. They're a great fit for handling custom domains in a variety of ways.

We can use plugs to do things like authorize requests for a custom domain, inject something into the request based on the custom domain, or practicaly anything else we might want to do.

For our example here, we're going to create a plug named CustomDomainsPlug, which we'll just use to fill a custom_domain field in the session to make it easy to grab elsewhere.

# in /lib/my_app_web/plugs/custom_domains_plug.ex
defmodule MyAppWeb.CustomDomainsPlug do
  @behaviour Plug
  import Plug.Conn

  @impl true
  def init(opts), do: opts

  @impl true
  def call(conn, _opts) do
    put_session(conn, :custom_domain, conn.host)
  end
end

Since we're using liveview for all of our pages in our example, we're going to handle the actual lookup and authorization in a liveview hook. We could handle it in the plug as well, but since it's technically possible to connect/reconnect to a websocket without hitting that plug, we opt for the liveview hook.

Remember the live session code in the router above? That's where we place our liveview hook. Here it is again on it's own, notice the on_mount and the layout fields:

# In the router inside the custom domains scope
live_session :custom_domain_blog, [
  # assigns the custom domain and blog struct to every liveview in this block
  on_mount: [{MyAppWeb.CustomDomainLiveviewHooks, :load_blog_for_custom_domain}],
  # sets the layout to one dedicated to loading the blogs on custom domains
  # Layout module located in my_app_web/components/layouts.ex below the default module
  layout: {MyAppWeb.CustomDomainLayouts, :root}
] do
  # The blog index/home page listing posts
  live "/", CustomDomainBlogLive, :index
  # An individual post loaded by post slug
  live "/:post_slug", CustomDomainBlogPostLive, :index
end

Here is the module containing the hook we listed in the on_mount above:

# In lib/web/my_app_web/custom_domain_liveview_hooks.ex

defmodule MyAppWeb.CustomDomainLiveviewHooks do
  @moduledoc """
  On mount hooks for assigning a custom domain if there is one,
  and assigning the appropriate blog if needed.
  """
  import Phoenix.Component
  alias MyApp.Blogs
  alias MyApp.Blogs.Blog

  def on_mount(:load_blog_for_custom_domain, _params, session, socket) do
    # We cache the blog lookup for 5 minutes in ETS,
    # to avoid a db lookup on every mount.
    # You might not want or need to do this in your app,
    # but the struct here is two fields and pretty light on memory.
    # Mostly, this is to show one way you could increase performance.
    blog =
      MyApp.SimpleCache.get(
        Blogs,
        :get_blog_by_custom_domain,
        [Map.get(session, "custom_domain")],
        # 5 mins
        ttl: 300
      )

    case blog do
      %Blog{} = blog ->
        {
          :cont,
          assign(
            socket,
            %{
              custom_domain: Map.get(session, "custom_domain"),
              # for our use case, the blog struct is pretty lightweight
              # so we assign the entire thing. In your use case, you might want to
              # assign just the id and load/stream it as needed in the actual liveview.
              blog: blog
            }
          )
        }

      _ ->
        # This converts to a 404 in phoenix
        raise Ecto.NoResultsError
    end
  end
end

This module returns a 404 for any requests on custom domains that we don't have in the database. When a request does match a custom domain in the database, it sets the custom domain and blog struct in the liveview assigns.

Bonus: you may have noticed the SimpleCache module we call above. That's a very handy (but optional) module for caching any function result using ETS. You can learn more about it in the Elixir School ETS article. It's simple and useful, but definitely not required.

Handling custom domain requests with Phoenix controllers

If you're not using liveview, you'll probably want to use the CustomDomainsPlug to do exactly what we just did in the liveview on_mount hook - checking against a cache or the database for an entity that has a matching custom domain, and setting it up to be conveniently used in your controllers.

Instead of setting liveview assigns however, you may want to use something like the SimpleCache to cache whatever entity might be tied to your custom domains, and add it to the conn for use in your controller.

That will save you the database lookup time, but it depends on the entity size and how much memory you want to spend on caching these things. It may be easier and more effective to simply pull the entity from the database every time, depending on your app.

We recommend splitting out custom domain specific features into their own controllers to make things conceptually simple. It also helps you avoid "crossing wires" and accidentally handling a request for the primary domain with custom domain logic, or vice versa.

For instance, you could have a controller for rendering a view for a store front, or for handling API calls specific to custom domains.

Bonus: Automating networking and SSL with Approximated

Remember how we said above that building a system for managing SSL certificates at scale is difficult, expensive, and error prone? Well, we know that from personal (painful) experience.

It's why we built Approximated - so that you don't have to. And at a price you can afford, starting at $20/month instead of thousands.

Approximated is your fully managed solution for automating custom domains and SSL certificates. We stand up a dedicated reverse proxy cluster, with edge nodes globally distributed around the world, for each and every customer.

You get a dedicated IPv4 address with each cluster so that you have the option of allowing your users to point their apex domains. Custom domain traffic goes through the cluster node nearest your user and straight to your app.

The best part? Your cluster will provision and fully manage SSL certificates automatically along the way. No need to worry about them ever again.

All you need to do is call our simple API from your app when you want to create, read, update, or delete a custom domain and it will handle the rest.

So what does that look like in our example so far?

The functions for saving a blog in our example look like this:

# in lib/my_app_web/live/blog_live/form_component.ex

defp save_blog(socket, :edit, blog_params) do
  case Blogs.update_blog(socket.assigns.blog, blog_params) do
    {:ok, blog} ->
      notify_parent({:saved, blog})

      old_cd = socket.assigns.blog.custom_domain

      # Update the Approximated.app virtual host for the custom domain, if there is one.
      # We handle this in a task so that you don't need an account to run this example.
      Task.start(fn ->
        cond do
          # If the blog custom domain was non-nil/empty and now it is nil or empty,
          # delete the Approximated virtual host instead of updating it
          (is_nil(blog.custom_domain) or String.trim(blog.custom_domain) == "") and
              (!is_nil(old_cd) and String.trim(old_cd) != "") ->
            MyApp.Approximated.delete_vhost(old_cd)

          # If the new one is different from the old, update it
          blog.custom_domain != old_cd ->
            MyApp.Approximated.update_vhost(old_cd, blog.custom_domain)

          # Otherwise do nothing (was blank before, is blank now)
          true ->
            nil
        end
      end)

      {:noreply,
        socket
        |> put_flash(:info, "Blog updated successfully")
        |> push_patch(to: socket.assigns.patch)}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign_form(socket, changeset)}
  end
end

defp save_blog(socket, :new, blog_params) do
  case Blogs.create_blog(socket.assigns.user_id, blog_params) do
    {:ok, blog} ->
      notify_parent({:saved, blog})

      # Create an Approximated virtual host to route and secure the custom domain.
      # We handle this in a task so that you don't need an account to run this example.
      unless is_nil(blog.custom_domain) or String.trim(blog.custom_domain) == "" do
        Task.start(fn ->
          MyApp.Approximated.create_vhost(blog.custom_domain)
        end)
      end

      {:noreply,
        socket
        |> put_flash(:info, "Blog created successfully")
        |> push_patch(to: socket.assigns.patch)}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign_form(socket, changeset)}
  end
end

In the example above, we're calling a module called Approximated to create or update a custom domain in the service depending on whether we're creating or updating the blog.

And this is what that Approximated module referenced above looks like with full doc blocks:

# in lib/my_app/approximated.ex
defmodule MyApp.Approximated do
  @moduledoc """
  A module for interacting with the Approximated.app API.

  Why do we need this?
  Well, there's 3 parts to handling custom domains for an app:

  1. DNS/Routing - Getting a request from user device to your app through a custom domain
  2. TLS/SSL - Securing the request for the custom domain
  3. App - Handling the custom domain request in the app itself

  Approximated nicely handles the first 2 for us so that we can focus on number 3.
  """

  # grab the primary domain from compile time env, or use localhost if it's nil
  @primary_domain Application.compile_env(:my_app, :primary_domains, ["localhost"])
                  |> List.first("localhost")

  def create_vhost(incoming_address) do
    json = %{
      incoming_address: incoming_address,
      target_address: @primary_domain,
      target_ports: "443"
    }

    Req.post("https://cloud.approximated.app/api/vhosts",
      json: json,
      headers: [api_key: Application.get_env(:my_app, :apx_api_key)]
    )
  end

  def update_vhost(current_incoming_address, new_incoming_address) do
    json = %{
      current_incoming_address: current_incoming_address,
      incoming_address: new_incoming_address,
      target_address: @primary_domain,
      target_ports: "443"
    }

    Req.post("https://cloud.approximated.app/api/vhosts/update/by/incoming",
      json: json,
      headers: [api_key: Application.get_env(:my_app, :apx_api_key)]
    )
  end

  def delete_vhost(incoming_address) do
    Req.delete("https://cloud.approximated.app/api/vhosts/by/incoming/#{incoming_address}",
      headers: [api_key: Application.get_env(:my_app, :apx_api_key)]
    )
  end
end

Between these two files, we're updating a domain field in the blog entity, and when we do we're using the Approximated module to call the Approximated API. That's all there is to it - you'll have fully automated custom domains from now on with automatically managed SSL certs.