A common requirement for applications is to have a subdomain per customer that users belonging to that customer can visit. Example of this include: Slack (https://elixir.slack.com, https://phoenix.slack.com, etc.) and ReadMe.

This blog post will go through how to set up your Phoenix application so that it can be used in the same way.

The source code for this repository is available at https://github.com/Gazler/phoenix-subdomain-demo - there is a commit to represent each step in this post.

Getting Started

The first thing we will need is a new Phoenix application. Since it is focused on subdomains, I am going to call it subdomainer:

mix phoenix.new subdomainer

Once the app has been created and all the dependencies have been installed, start the app and visit it at http://localhost:4000.

mix phoenix.server

Next we need to ensure that it is accessible via a separate domain and subomains, so the following needs to be added to your /etc/hosts file:

127.0.0.1 subdomainer.dev foo.subdomainer.dev bar.subdomainer.dev

With these additions, you should also be able to access the application via: http://subdomainer.dev:4000, http://foo.subdomainer.dev:4000 and http://bar.subdomainer.dev:4000

Currently these all point to the same page, but we are going to modify it so that it displays information about the particular app that we are trying to visit.

Determining If A Subdomain Has Been Set

With the app as it stands, if a user visits http://subdomainer.dev:4000 then we want to display the default Phoenix page that was generated with the application. However if they visit http://foo.subdomainer.dev:4000 or any subdomain then we want to show a different page. For the moment, we won’t worry too much about which subdomain is being viewed, only that there is a subdomain present.

We need to configure the application so that it knows which domain is the root domain. This is because you cannot make any guarantees about the number of subdomains. In this instance we know that subdomainer.dev is the root and foo.subdomainer.dev is a subdomain - however our root could be app.subdomainer.dev and the subdomain could be foo.app.subdomainer.dev

Replace the following in config/config.exs under the config :subdomainer, Subdomainer.Endpoint block that should be at the top of your file:

1 url : [ host : "localhost" ]

With:

1 url : [ host : "subdomainer.dev" ]

We now need to update our endpoint so it knows if a subdomain has been provided in the URL. Create a file lib/subdomainer/plugs/subdomain.ex file with the following:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 defmodule Subdomainer.Plug.Subdomain do import Plug.Conn @doc false def init ( default ), do : default @doc false def call ( conn , router ) do case get_subdomain ( conn . host ) do subdomain when byte_size ( subdomain ) > 0 -> conn |> router . call ( router . init ({})) _ -> conn end end defp get_subdomain ( host ) do root_host = Subdomainer.Endpoint . config ( :url )[ :host ] String . replace ( host , ~r/.? #{ root_host } / , "" ) end end

This code here implements the call/2 function expected by plug. This second argument we expect is the module that will be used if a subdomain is found. This also needs to be added to lib/subdomainer/endpoint.ex so that we can ensure our plug is called before the router. Add the following line before the plug :router, Subdomainer.Router line:

1 plug Subdomainer.Plug.Subdomain , Subdomainer.SubdomainRouter

You will need to restart your server and start it again (with mix phoenix.server ) every time you make a change to a file in the lib directory as only changes in the web directory do not require a reload.

You can validate that this is working by visiting http://subdomainer.dev:4000 which will still work as before, however if you visit http://foo.subdomainer.dev:4000 then you will see an error:

undefined function: Subdomainer.SubdomainRouter.init/1 (module Subdomainer.SubdomainRouter is not available)

This is because this router does not exist yet.

Adding The Subdomain Router

To fix the error we just need to create the SubdomainRouter at web/subdomain_router.ex :

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 defmodule Subdomainer.SubdomainRouter do use Subdomainer.Web , :router pipeline :browser do plug :accepts , [ "html" ] plug :fetch_session plug :fetch_flash plug :protect_from_forgery end scope "/" , Subdomainer do pipe_through :browser # Use the default browser stack get "/" , PageController , :index end # Other scopes may use custom stacks. # scope "/api", Subdomainer do # pipe_through :api # end end

This fixes the error and now both http://subdomainer.dev:4000 and http://foo.subdomainer.dev:4000 work as before.

We want the subdomain pages to route somewhere else though - we can simply modify the scope block in the new router to point to a different controller by changing it to:

1 2 3 4 5 scope "/" , Subdomainer.Subdomain do pipe_through :browser # Use the default browser stack get "/" , PageController , :index end

This will error until you create the required controller, so create web/controllers/subdomain/page_controller.ex :

1 2 3 4 5 6 7 8 defmodule Subdomainer.Subdomain.PageController do use Subdomainer.Web , :controller def index ( conn , _params ) do text ( conn , "Subdomain home page" ) end end

You will note that PageController has been used as the name both times. This name is not important, it just needs to match the path from the scope block in the router.

This will work, but you will probably see the following error in your terminal:

(exit) an exception was raised: ** (Plug.Conn.AlreadySentError) the response was already sent

This is simple to fix - we just need to prevent additional plugs from running if a subdomain is found modify lib/subdomainer/plugs/subdomain.ex to include a call to Plug.Conn.halt/1:

1 2 3 4 5 6 7 8 9 def call ( conn , router ) do case get_subdomain ( conn . host ) do subdomain when byte_size ( subdomain ) > 0 -> conn |> router . call ( router . init ({})) |> halt _ -> conn end end

Customize response based on subdomain

The last thing to do for this is to customize the response based on the which subdomain has been visited. To do this, we will add it to the private storage that exists in a Plug.Conn which you can read about in the Plug docs

We will do this in the Subdomainer.Router module where we did the initial check to see if a subdomain exists by modifying the call function:

1 2 3 4 5 6 7 8 9 10 def call ( conn , opts ) do case get_subdomain ( conn . host ) do subdomain when byte_size ( subdomain ) > 0 -> conn |> put_private ( :subdomain , subdomain ) |> router . call ( router . init ({})) |> halt _ -> conn end end

We can then retrieve this value in the index action of our Subdomainer.Subdomain.PageController like so:

1 2 3 def index ( conn , _params ) do text ( conn , "Subdomain home page for #{ conn . private [ :subdomain ] } " ) end

And that’s it! All of the following pages should work and show the correct page http://subdomainer.dev:4000, http://foo.subdomainer.dev:4000 and http://bar.subdomainer.dev:4000