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:
- The main frontend is public while the admin side only has private access.
- 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.
- 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.
- 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(conn.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.
We had a blast 💥thinking about all the possibilities of Umbrellas and Routing. We hope it set your mind on 🔥fire as well. If you want to get a regular dose of ⚗️Elixir Alchemy, subscribe to get the next episode of Alchemy delivered straight to your inbox.
This post is written by guest author Miguel Palhas. Miguel is a professional over-engineer @subvisual and organizes @rubyconfpt and @MirrorConf.
Top comments (3)
I love this post! I'm so excited to implement this myself - the "poor man's microservice" is sweet. One question - the first
with
statement uses a variablehost
which doesn't appear to be in the scope. Can you explain please?Thanks for the feedback! It appears you caught a typo in the post.
It was suposed to be
conn.host
. Already edited with a fix :)Darn, I was hoping there was some Elixir magic I was unaware of!