We can't find the internet
Something went wrong!
Deploying a Phoenix app at a configurable path
- Created: 28/08/2024
- Last updated: 10/10/2024
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
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.
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:
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.