Byron Salty
Posted on February 6, 2024
Let's say you have a product you are creating using Elixir/Phoenix but for marketing purposes you also setup a blog on a separate site like Wordpress.
It makes sense to keep this separation of concerns and let some off the shelf software worry about creating a blog experience, while you work on your primary product.
However, for user experience and SEO purposes you may want to have your applications appear to come from a single domain. I did a quick search for the importance of a single domain vs subdomains and the first article I found also uses this exact blog use case as their example.
Today I'll use just a couple bits of code and configuration to make to solve this problem directly in Phoenix. No access to your https proxy (nginx, caddy, etc) required!
Goal:
Create a proxy endpoint that will take all traffic to my primary domain mydomain.com/blog and route it over to the actual host of blog.mydomain.com
One reason I wanted to do this all in Elixir/Phoenix was I like to use Fly.io and their setup is super simple and effective... but I don't have direct access to their load balancer where I might otherwise setup a proxy like this.
The other thing that is nice about this setup is I will be able to use Elixir to manipulate the upstream request and response, which will come in handy as I make the experience seamless.
Step 1: The Proxy
I used the Elixir library reverse_proxy_plug
which has the very nice benefit of not assuming anything about the hosting for the upstream service. For instance, it didn't need to be another Phoenix app or a service running on the same host.
Add this to your deps in mix.exs
:
{:reverse_proxy_plug, "~> 2.3"},
Then you can add this to your router.ex
:
scope "/blog" do
pipe_through [:browser]
forward "/", ReverseProxyPlug, upstream: "https://blog.mydomain.com", response_mode: :buffer
end
Note that my use case is not high volume so I didn't really worry about different response modes. I believe the :buffer
option works well enough for me here.
At this point, you should be able to hit your project at /blog and get some html that really came from your upstream service.
But there's a problem...
Step 2: Rewrite the content
The problem now is that the html that you will get will not know that you're trying to serve from mydomain.com/blog
and will still have all links etc pointing to blog.mydomain.com
.
No problem, we just need to alter the html response on the way out so that all of these references are updated.
This was a bit tricky because the conn
struct is very particular about how it can be interacted with, stemming from the fact that HTTP is similarly and correctly picky. It wouldn't make sense if you could alter a response after you've already sent or even began sending it back to the client. So we need to pinpoint the moment where we have the response body but we are allowed to alter it.
Luckily, Plugs give us this exact ability in the standard function Plug.Conn.register_before_send/2
. This gives us the opportunity to define a function that will be called with the Conn after the response is ready but before it's actually given back to the client.
We just need to define a custom plug.
Update the scope and define a new pipeline in router.ex
:
pipeline :transform_blog do
plug BuddyWeb.Plugs.TransformBlog
end
scope "/blog" do
pipe_through [:transform_blog]
forward "/", ReverseProxyPlug, upstream: "https://blog.mydomain.com", response_mode: :buffer
end
And here's the entire TransformBlog
plug:
defmodule BuddyWeb.Plugs.TransformBlog do
def init(options), do: options
def call(%Plug.Conn{} = conn, _ \\ []) do
Plug.Conn.register_before_send(conn, &BuddyWeb.Plugs.TransformBlog.transform_body/1)
end
def transform_body(%Plug.Conn{} = conn) do
case List.keyfind(conn.resp_headers, "content-type", 0) do
{_, "text/html" <> _} ->
body =
conn.resp_body
|> String.replace("blog.mydomain.com", "mydomain.com/blog")
%Plug.Conn{conn | resp_body: body}
type ->
IO.inspect(type, label: "content type header")
conn
end
end
end
Thanks to Curiosum for their article on this using Plugs in this way. My code borrows heavily from their example.
But there's one more problem...
Note: If you test the code at this point you may notice that you get expected results in curl
or wget
but not in a browser.
Step 3: Dealing with encodings
The reason why browsers give a different response than the command line tools are the accept-encoding
headers are different.
The command line tools are essentially asking for a response in a text format so our string replace is working and we get the result we expect.
But browsers will ask for a response in gzip or br encoding. Binary encodings that will only get turned back into text on the end-user's client so our string replace will not work.
The fix I employed here was to override the accept-encoding
header to force a non-binary response from our upstream service. This will be less efficient but fine for my purposes and scale.
Update the same TransformBlog
plug to change the request headers on the way in:
def call(%Plug.Conn{} = conn, _ \\ []) do
Plug.Conn.register_before_send(conn, &BuddyWeb.Plugs.TransformBlog.transform_body/1)
|> Plug.Conn.update_req_header(
"accept-encoding",
"identity",
fn _ -> "identity" end
)
end
The update_req_header()
function asks you to specify both the default value (in case the header doesn't already exist) and a function to manipulate the existing header. In my case, I don't care what the previous header was - I'm just overriding to use identity
which means no modification or compression.
That's all there is to it - one dependency to configure and one plug.
Happy Proxying~!
Posted on February 6, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.