Routing in Phoenix Umbrella Apps

Published on

Note: This post was originally written on AppSignal's Elixir Alchemy Blog

Umbrella apps are an awesome way to structure Elixir projects.

Behind the curtains, they are a very thin layer that just compiles everything to a single package. Instead of building a single large monolith, you can structure your code with multiple isolated contexts. They all get compiled and run under the same BEAM instance, so they still have access to each other. Meanwhile the conceptual separation ensures you have separate OTP apps for each of your umbrella children. And it allows you to work on each of them with a certain degree of isolation.

Think of this as a poor man’s microservices solution. You don’t need to add a messaging queue or send HTTP requests between each service since they’re all actually running under the same process, but you still get some of the benefits.

If you want to know more about umbrella applications, I suggest the official guide as a starter, as it clearly outlines the advantages and caveats of umbrella apps.

Now let’s look at a real life example where I’ve implemented an umbrella app.

A Real Example

Let’s say I’m building a website for Magic: The Gathering (MTG) cards. Which… well, I am. The idea is to create an interface where users can browse and search a database of cards. There’s also an admin panel where some administrative tasks can be performed.

Clearly, each of these frontend interfaces has different requirements:

  1. The main frontend is public while the admin side only has private access.
  2. The admin panel may even have its own UI requirements. In this case, I’m using ex_admin for convenience. This means, even UI assets are not shared.
  3. They mostly have completely different back-end logic as well. Only a small subset of the queries and operations can be shared between the two.
  4. I may also want to access both of them through different URLs (e.g. use an admin subdomain for the Admin frontend). The multiple differences between the two make it clear that it would be better for these to be two separate phoenix apps—each with its own setup.

Something like this:

apps/
  client/
  admin/
  shared/

Looks Easy Enough. What’s the Issue?

The problem comes when you try to figure out how to actually implement this. How do you route requests from the admin subdomain to another Phoenix app while routing other requests to the main Phoenix app?

One solution would be to run each of those apps on a different port. But then, you’ll either be left accessing admin.mydomain.com:4001, or you’ll need some other middle layer to abstract away that port distinction from your browser. While this may be fine for an admin page that only you will access, it doesn’t work as well for a general solution.

The old school solution is to put a reverse proxy between your clients and your server. nginx does this job pretty well. But in reality, you know all this is a single Elixir application. It seems weird to need a third party server to be able to route requests to different parts of it.

It also doesn’t solve the problem of local development, unless you want to run nginx locally as well, which is less than ideal.

We’re Elixir developers after all, and we’re pretty smart. So let’s do this the Elixir way:

Introducing a Proxy App

The solution I came up with (i.e. read suggestions from similar use cases on Stack Overflow) was to create an additional umbrella child, which will be the main point of contact to the outside world.

This app, which we’ll call proxy, will receive all incoming HTTP requests and forward them to the appropriate Phoenix app, based on a few simples rules. In our simple use case, requests to admin.mydomain.com will be forwarded to the admin app, and all others will be forwarded to the client app.

This is a very simple phoenix app, which you can generate with mix phx.new like all the others. Dependencies will be kept to a minimum here. We only have phoenix & cowboy as external dependencies (to set up our web server), as well as the client and admin apps to which we’ll be forwarding requests:

def deps do
  [
    {:client, in_umbrella: true},
    {:admin, in_umbrella: true},
    {:phoenix, "~> 1.3.2"},
    {:cowboy, "~> 1.0"}
  ]
end

Since this app will be the actual web server, we should disable the server setting in the other two:

# apps/client/config/config.exs
config :client, Client.Web.Endpoint, server: false

# apps/admin/config/config.exs
config :admin, Admin.Web.Endpoint, server: false

# apps/proxy/config/config.exs
config :proxy, Proxy.Endpoint, server: true

This ensures that only the proxy app will be listening to a port. This is not mandatory but it saves you the trouble of having to define different ports for each one (remember: only one listener per port is allowed) and ensures all requests actually go through the proxy app—which is indeed the expected behavior.

Leaving server: true might be useful in development or testing mode, depending on how you want to set up your environment.

Setting up the Endpoint

The entry point of a Phoenix app is the Endpoint module. In this case, we’ve set this to Proxy.Endpoint. Since this app really has no other responsibility, there’s no need to nest it under the Web module, as is common practice in Phoenix.

However, we can strip down most things from the Endpoint module created for us by the Phoenix generator and end up with a very simple module:

defmodule Proxy.Endpoint do
  use Phoenix.Endpoint, otp_app: :proxy

  @base_host_regex ~r|\.?mydomain.*$|
  @subdomains %{
    "admin" => Admin.Web.Endpoint,
    "client" => Client.Web.Endpoint
  }
  @default_host Client.Web.Endpoint

  def init(opts), do: opts

  def call(conn, _) do
    with subdomain <- String.replace(host, @base_host_regex, ""),
         endpoint <- Map.get(@subdomains, subdomain, @default_host) do
      endpoint.call(conn, endpoint.init())
    end
  end
end

Let’s go over this one step at a time:

@base_host_regex ~r|\.?mydomain.*$|

This is used to extract the subdomain part of the host URL of every request. So for admin.mydomain.com we want to get the string admin and for mydomain.com we will end up with an empty string (meaning, we’ll forward this to the default app. More on that later).

Notice that this doesn’t exactly match the .com part. This is a convenience change I made for local development. Matching on mydomain.* allows me to use admin.mydomain.lvh.me when working on my local machine, and still have this whole logic working without making development-specific changes.

If you don’t know what lvh.me is, this article might be helpful (TL;DR: It’s a development service that resolves its name to localhost).

With the above regex in mind, the next part should be easy to understand:

@subdomains %{
    "admin" => Admin.Web.Endpoint,
    "client" => Client.Web.Endpoint
}
@default_host Client.Web.Endpoint

For every subdomain, we want to match a particular Phoenix endpoint belonging to the app that we want to forward the request to. @default_host is what we’ll use if the subdomain is missing (the empty string scenario we talked above).

def call(conn, _) do
    with subdomain <- String.replace(host, @base_host_regex, ""),
         endpoint <- Map.get(@subdomains, subdomain, @default_host) do
      endpoint.call(conn, endpoint.init())
    end
end

When this endpoint—which is actually not much more than an Elixir Plug—is called, we just grab the subdomain from the request host, then find the matching endpoint from our mapping (defaulting to @default_host), and call endpoint.call/2 on it. This is essentially delegating the call down to the appropriate app.

Now client and admin both have to only worry about their corresponding requests and authentication. All logic related to the multiple subdomains & clients we may need is abstracted away in this app.

Want a new client in the same umbrella? Add it here! Want the same endpoint to respond to additional subdomains? Add it here!

Taking the routing even further

By adding a smart router to our umbrella application, we’re now able to serve requests to different subdomains to different apps in our umbrella application. I first implemented this pattern on a pet project of mine, but have since used and improved it on a few production projects as well.

We could take this much further. For example, if you’re migrating an existing service from Ruby to Elixir. You can have this proxy application route all requests made to the Ruby version of your service redirected back to the Ruby application, ensuring backward-compatibility. Or you may want the opposite scenario, where you’re creating a new API service and want to forward matching requests to a different client or even to a different web server altogether.

We can also take the routing complexity to another level. Routing was done here based solely on the subdomain of the request. But depending on your needs, you can create more complex routing rules using HTTP headers or query parameters. All of this can be done while keeping your actual web services completely oblivious to it.