Whether you've got technical questions or just want to learn more, we're happy to dig into it with you. Get answers from a real, human engineer over email or on a call.
Tutorial
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:
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.
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.
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 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.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.
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.
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:
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.
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:
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:
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.
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.
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.
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.
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.
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.