Deploying a Phoenix app at a configurable path

Deploying a Phoenix app at a configurable path
  • Created: 28/08/2024
  • Last updated: 10/10/2024
If you are just here for the code, my solution with copy and pastable examples is at the bottom. Also there is a link to a git repo with working dockerized demo phoenix app served at a subdirectory behind Nginx.

Whats this?

Today I was looking into deploying a phoenix web application at a configurable path or subdirectory, behind a reverse proxy. (Nginx in my case, but they all do roughly the same job).

Interestingly, I haven’t encountered this issue before because my projects have typically been structured in one of two ways: either as a RESTful API on its own subdomain or statically scoped to /api within the router, or as a standalone Phoenix LiveView app deployed at the domain root.

This one has more parts to it, with RESTful API endpoints, as well as an admin area, and live view. It will be served on a subdirectory on the same domain as some other services, so, its time to work out how to do this properly.

If you want to follow along, you will need a working Nginx and phoenix installs. And optionally Docker too. Make yourself a nice new Phoenix app, and we will be ready to get started.

I am on the following versions:

  • Elixir version: 1.16.3 (OTP 26)
  • Phoenix version: 1.7.14
  • Nginx version: 1.27.0
  • Docker version: 26.1.3

There will be a link to a git repo at the end with a working demo project, but this wont be a beginners “how to do a Phoenix tutorial” (or Nginx), as there are already plenty of those elsewhere.

Why is this?

At first I did the usual, I searched for solutions online which I could copy, paste, and claim as my own. Then looked for specific documentation.

A few suspiciously complex looking tutorial type blog posts came up, I tried the most hopeful looking one, which, didn’t solve the problem sufficiently.

However, it did have a clue to begin with in Endpoint configuration:

url: [path: "/some_subdirectory", ...],

So I looked up phoenix endpoint docs, and found this:

:url - configuration for generating URLs throughout the app. Accepts the :host,
:scheme, :path and :port options. All keys except :path can be changed at
runtime.

That’s cool, its a starting point, but doesn’t really give a full solution, and probably isn’t really intended to.

Goal setting

An acceptable solution will meet the following criteria:

  • The application will run as intended when served from a sub directory path.
  • I want to keep development simple, the app should run standalone at the domain root, and without the need for a proxy server in development.
  • We will have a single source of truth when it comes to URL paths.

First attempt

Keep on scrolling if you are looking for working code, you haven't got to that bit yet.

So having learned of the endpoint URL path option, I tried it out in development mode.

Following the previously mentioned tutorial, I set path to /some_subdirectory in dev.exs, started the dev server, to see what happens

  config :demo, DemoWeb.Endpoint,
    url: [path: "/some_subdirectory"],
  ...

Now all of the generated URIs rendered on the front end have changed as expected, but none of the back end routes have changed to match them.

This should have been enough of a clue for me to come to a solution, but having seen this in one of the afore mentioned tutorials, I carried on trying to solve this within phoenix for a bit.

Although rendered URLs included my path as expected, there were now no static assets, and no WebSocket for live view to connect to.

In an attempt to fix this, I updated the path for Plug.Static, and live view path in endpoint.ex, and tried again.

  socket "/some_subdirectory/live", Phoenix.LiveView.Socket,
    websocket: [connect_info: [session: @session_options]],
    longpoll: [connect_info: [session: @session_options]]

  ...

  plug Plug.Static,
    at: "/some_subdirectory",
    from: :demo,
    gzip: false,
    only: DemoWeb.static_paths()

Also so that LiveSocket can connect, I updated its path in app.js:


const liveSocket = new LiveSocket("/some_subdirectory/live", Socket, {
  ...
})

Now LiveSocket connects to its WebSocket, and routes work when rendered via live view, but not when you navigate directly to a route.

This seemed hopeful, just update the router, and were there right? So I put everything in a /some_subdirectory scope:

  scope "/some_subdirectory" do

   ...

  end

Enough of these games. Although this sort of works, we are failing to meet some of the criteria set above. It bothers me that the path from our configuration file is now repeated in about ten places. Also, if this path changes, they all have to change. Especially because this arbitrary path is kind of nginx’s responsibility anyway. Ontop of this, its kind of annoying to go to localhost:4000/some_subdirectory, rather than localhost:4000 to look at the site as I work on it.

There is a branch in the repo linked below called wrong showing this in action.

Lets roll all of that back and try again.

My solution

First off, we will set the endpoint URL path config option, I did this in runtime.exs, but this could also be done in prod.exs if you like. Using runtime.exs gives you the option to make it configurable at runtime via environment variables.

  config :demo, DemoWeb.Endpoint,
    url: [path: "/some_subdirectory", ...],
    ...

This part is the same as before. It tells phoenix to generate URLs within this subdirectory. We do not need to update the router, as later on our reverse proxy config to deal with this.

Next, this path needs to be available to our front end code, so that it too can generate working URLs.

In root.html.heex we will add the following in the head section of our html, before app.js is referenced.

<html>
  <head>
    ...

    <script>
      window.__APP__  = window.__APP__ || {}

      window.__APP__.pathPrefix = "<%= Application.get_env(:demo, DempWeb.Endpoint)[:url][:path] %>"
    </script>

    ...

    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}></script>
  </head>

  ...
</html>

This is a common pattern used to pass otherwise unreachable data from the server to your front end.

By checking for existence of window.__APP__ it means we can pass data from the server in this way in multiple places if needed. Also the __APP__ object acts as a namespace for this data to avoid unexpected collisions in the global namespace later on.

Our front end code now needs to know where to look for the phoenix WebSocket connection, so look this up in app.js, and use it to build the live socket path.

...

const {pathPrefix} = window.__APP__

...

const liveSocket = new LiveSocket(pathPrefix + "/live", Socket, {
  longPollFallbackMs: 2500,
  params: {_csrf_token: csrfToken}
})

...

Now our Phoenix application is ready to go, all that is left is to configure Nginx. We will use proxy_pass to chop /some_subdirectory/ (or whatever path you like) out of the URL, and replace it with / before passing requests on to Phoenix. Meaning that whatever sub directory path we configure, the router will still work without any changes.

Careful about subtle differences in the `proxy_pass` value. That trailing slash is important. This part is not at all obvious without reading the Nginx docs on proxy_pass!
server {
...

  location /some_subdirectory {
    ...

    proxy_pass http://demo:4000/;

    ...
  }

...
}

Where http://demo:4000 is the route to my Phoenix server.

And that’s it! Now our application can be configured to run at whatever subdirectory path we like, and as long as it lines up with the Nginx location block, everything will work fine with minimal fuss.

As promised, here is an example repo with a working example:

example repo

It uses docker compose to run everything, and has instructions in its README.md file.

Conclusion

It works, and ontop of that, the solution is simple and re-usable for future projects.

My solution also meets all of my goals from the start:

  • Everything runs as intended whatever subdirectory path we set (or dont set).
  • It doesn’t add complexity to development workflow because this is unaffected by these changes.
  • The single source of truth (within Phoenix codebase) makes it easy to change and re-deploy at a different location in future.
  • Apart from the location in Nginx having to match the path configured in Phoenix, there is no other coupling between the two services.

It was not that complex in the end, but after I had to work it out for myself, it felt to me like this information should have been available somewhere. Also, I haven’t posted on here in a very long time. Hopefully this is useful to someone else one day.

References

tldr: Serving a Phoenix LiveView application at a configurable subdirectory path, behind an Nginx reverse proxy. Code examples and working demo repo at the end.