Adding /blog proxy to a Phoenix app
Tags: seo, phoenix, proxy
Published: 2024-02-06 05:46:51

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](https://www.ecenica.com/blog/seo-benefits-of-using-one-domain-vs-subdomains/) 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](https://curiosum.com/til/process-phoenix-conn-after-render-response) 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding).

---

That's all there is to it - one dependency to configure and one plug.

Happy Proxying~!