Phoenix application behind AWS ALB

Hi,

I’m running a Phoenix application behind a AWS ALB (Application Load Balancer), which routes requests to different applications based on the path in the requested URL.

Requests that start with /myapp are routed to my application.

The problem is that ALB doesn’t support stripping this /myapp prefix from the actual URL that goes to Phoenix (differently from nginx, haproxy, etc) and I wouldn’t like to add this prefix (a deployment configuration) to all my routes as it can only be done in compilation time (at least without having to create a router myself which I think would be overkill; I might be wrong though).

In an attempt to overcome this issue, I’ve created a simple plug that runs before everything and removes the prefix from connection’s path_info. For instance, it turns

["myapp", "assets", "app-978a7188b69b3752fe7c58e50c7e5571.js"]

into

["assets", "app-978a7188b69b3752fe7c58e50c7e5571.js"]

Also, I configure my Endpoint to use this prefix as the path so URLs generated by the application include the prefix back and are properly routed by ALB.

# runtime.exs
config :myapp, MyAppWeb.Endpoint,
  url: [
    host: get_env("PHX_HOST", "localhost"),
    port: get_env("PHX_PORT", 4000, :int),
    path: get_env("PHX_PATH", "/") # get_env is just a helper func
  ]

It works fine for assets and regular routes, but not for sockets and anything that uses sockets like live reloading and the dashboard. Example:

17:39:09.218 [info] GET /myapp/socket/websocket
17:39:09.280 [debug] ** (Phoenix.Router.NoRouteError) no route found for GET /socket/websocket (MyAppWeb.Router)
    (myapp 1.0.0-alpha.19) lib/phoenix/router.ex:405: MyAppWeb.Router.call/2
    (myapp 1.0.0-alpha.19) lib/myapp_web/endpoint.ex:1: MyAppWeb.Endpoint.plug_builder_call/2 

The plug feels like a hack and it didn’t solve my problem.

Have you guys had to deal with a similar deployment? How did you manage?

Cheers!

If your app deployment target for production is to be behind that aws load balancer why do you want to overcome the issue in your app? Why not keeping it simple and just add the prefix?

Doesn’t the load balancer support to invoke a lambda function?

Or if your domain is with Route53 you can try to use forwarding rules:

Thanks @Exadra37 for taking the time to read my post, I really appreciate it.

I don’t :slight_smile: I just don’t know how.

Add the prefix where? To my application’s router scope’s ?

It does. Not sure how it would help specially because my app uses websockets.

It is not but I have no problems moving to it if it solves the issue. I’m not sure though as I think DNS usually doesn’t get involved with URL paths, right?

Yes, and in the sockets configuration on the endpoint module.

Read the link I shared.

Because the prefix is a deployment-specific configuration. I should not need to re-compile the application to change it.

Also, it breaks Phoenix’s LiveDashboard (it adds the prefix twice to the URL) and live reload (dev only):

13:21:42.756 [info] GET /myapp/phoenix/live_reload/frame
13:21:42.843 [debug] ** (Phoenix.Router.NoRouteError) no route found for GET /myapp/phoenix/live_reload/frame (MyAppWeb.Router)
    (myapp 1.0.0-alpha.19) lib/phoenix/router.ex:405: MyAppWeb.Router.call/2
    (myapp 1.0.0-alpha.19) lib/myapp_web/endpoint.ex:1: MyAppWeb.Endpoint.plug_builder_call/2

I will. Thanks again.

You didn’t mention that was deployment specific. But you may try to read the prefix from the runtime.exs configuration, thus no need to recompile the app, only needs to setup an env var with the prefix.

Where is it in the logs?

Please add your router file to your first post so we can better understand what you are doing.

That’s what I do for all my runtime configuration and that was my first attempt before starting this thread, but I soon realized that the scope in Phoenix’s router is evaluated at compilation time. So I can’t use things like Application.fetch_env!(:myapp, :prefix).

If I do like:

  scope Application.fetch_env!(:myapp, :prefix), MyAppWeb do
    pipe_through :browser
    get "/users", UserController, :index
  end

I correctly get a compilation error:

== Compilation error in file lib/myapp_web/router.ex ==
** (ArgumentError) could not fetch application environment :prefix for 
application :myapp because the application was not loaded nor configured

I couldn’t figure out how to edit my original post so here it goes.

Router with all the routes statically prefixed with the “/myapp”:

defmodule MyAppWeb.Router do
    use MyAppWeb, :router
    import Phoenix.LiveDashboard.Router

    pipeline :browser do
      plug :accepts, ["html"]
      plug :fetch_session
      plug :fetch_live_flash
      plug :put_root_layout, {MyAppWeb.LayoutView, :root}
      plug :protect_from_forgery
      plug :put_secure_browser_headers
    end
  
    pipeline :basic_auth do
      plug :basic_auth_plug
    end
  
    pipeline :api do
      plug :accepts, ["json"]
    end
  
    scope "/myapp", MyAppWeb do
      pipe_through :browser
  
      get "/", PageController, :index
    end
  
    scope "/myapp/dashboard" do
      pipe_through [:browser, :basic_auth]
      live_dashboard "/", metrics: MyAppWeb.Telemetry
    end
  
    defp basic_auth_plug(conn, _opts) do
      (...)
    end
  end
  

You didn’t ask but I guess the Endpoint is also relevant as the sockets are defined there:

Endpoint with all the sockets statically prefixed with the “/myapp”:

defmodule MyAppWeb.Endpoint do
    use Phoenix.Endpoint, otp_app: :myapp
  
    @session_options [
      store: :cookie,
      key: "_myapp_key",
      signing_salt: "*********"
    ]
  
    socket "/myapp/socket", MyAppWeb.UserSocket,
      websocket: true,
      longpoll: false
  
    socket "/myapp/live", Phoenix.LiveView.Socket,
      websocket: [connect_info: [session: @session_options]]
  
    plug Plug.Static,
      at: "/myapp",
      from: :myapp,
      gzip: false,
      only: ~w(assets fonts images favicon.ico robots.txt)
  
    if code_reloading? do
      socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket  # note 1
      plug Phoenix.LiveReloader
      plug Phoenix.CodeReloader
    end
  
    plug Phoenix.LiveDashboard.RequestLogger,
      param_key: "request_logger",
      cookie_key: "request_logger"
  
    plug Plug.RequestId, http_header: "x-correlation-id"
    plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
  
    plug Plug.Parsers,
      parsers: [:urlencoded, :multipart, :json],
      pass: ["*/*"],
      json_decoder: Phoenix.json_library()
  
    plug Plug.MethodOverride
    plug Plug.Head
    plug Plug.Session, @session_options
    plug MyAppWeb.Router
  end

Note 1: I tried with socket "/myapp/phoenix/live_reload/socket" too but it doesn’t work either.

I though that could be the case after I suggested it.

Sorry for the misleading tip but my Elixir experience is limited to toy apps, thus I am not yet very fluent on it yet.

after some time elapses we cannot edit it any-more.

You also need to update assets/js/app.js:

let liveSocket = new LiveSocket("/myapp/live", Socket, {params: {_csrf_token: csrfToken}})

Don’t do that. all you need to do are:

  • change the url key of your Endpoint config with a non-root url.
  • change the js for the lv or socket url if you use channel or liveview. I usually add a data attribute in my <body> tag to pass the correct url (derived from above) to the client side

Hi Derek, thanks for joining the conversation.

When you say “Don’t do that” what are you referring to?

I understand your suggestion is the standard approach when the load balancer can re-write the URL, which unfortunately is not the case:

So the request reaches the router with the prefix, hence it is not found.

With url config in place, every request reaching plug would need to have the prefix to be processed. So, you need to add the prefix for the socket connect, as the standard js from a phx.new don’t have it. This is my second point.

I am not sure about AWS ALB, but this is how multiple phoenix apps work behind a single nginx reverse proxy.

So, you mean to add to url in runtime.exs the following:

config :api_baas, ApiBaasWeb.Endpoint,
    url: [host: apibaas.io/myapp, port: 443, scheme: "https"],

If this works I was not aware, and that’s solves the problem really well.


I totally forgot about this old proof of concept of mine:

In this PoC you can also find this approach to handle this path situation:

# file: ./phoenix360/config/runtime.exs

config :phoenix360, Phoenix360Web.Endpoint,
  http: [
    port: phoenix360_http_port,
    transport_options: [socket_opts: [:inet6]],
    # @link https://ninenines.eu/docs/en/cowboy/2.7/guide/routing/
    dispatch: [
      {
        phoenix360_host,
        [
          # @PHOENIX_360_WEB_APPS - Each included 360 web app needs to have an
          #   endpoint in the top level 360 web app. This is necessary in order
          #   for the top level 360 web app to know how to dispatch the requests.
          #   For example a request to app.local/_links/* will be dispatched to
          #   the `/links/*` endpoint for the LinksWeb.Router.
          #
          # {:path_match, :handler, :initial_state}
          {"/_links/[...]", Phoenix.Endpoint.Cowboy2Handler, {LinksWeb.Endpoint, []}},
          {"/_notes/[...]", Phoenix.Endpoint.Cowboy2Handler, {NotesWeb.Endpoint, []}},

          # @PHOENIX_360_WEB_APPS - This tells that all other request must be
          #   dispatched to the Phoenix360Web.Router, aka the one for the top
          #   level 360 web app.
          {:_, Phoenix.Endpoint.Cowboy2Handler, {Phoenix360Web.Endpoint, []}},
        ]
      },
      {
        links_host,
        [
          # {:path_match, :handler, :initial_state}
          {:_, Phoenix.Endpoint.Cowboy2Handler, {LinksWeb.Endpoint, []}},
        ]
      },
      {
        notes_host,
        [
          # {:path_match, :handler, :initial_state}
          {:_, Phoenix.Endpoint.Cowboy2Handler, {NotesWeb.Endpoint, []}},
        ]
      }
    ]
  ]

# @PHOENIX_360_WEB_APPS - No need to have a server running for `:links`, because
#    the requests will be handled by the Cowboy server for the main 360 web app
#    `phoenix360`:
config :links, LinksWeb.Endpoint,
  server: false

# @PHOENIX_360_WEB_APPS - No need to have a server running for `:notes`, because
#    the requests will be handled by the Cowboy server for the main 360 web app
#    `phoenix360`:
config :notes, NotesWeb.Endpoint,
  server: false

The project contains a full step by step to reproduce a working example.

Unfortunately that’s not all we need to do. To demonstrate it, one can do:

  1. Start fresh with mix phx.new hello --no-ecto
  2. Apply your first point, i.e., add the path to the Endpoint:
# runtime.exs
  config :hello, HelloWeb.Endpoint,
    url: [host: host, port: 443, scheme: "https", path: "/myapp"],
  1. Apply your second point:
// app.js
let liveSocket = new LiveSocket("/myapp/live", Socket, {params: {_csrf_token: csrfToken}})
  1. Start the application with mix phx.server

  2. Access http://localhost:4000/myapp

  3. Observe that you got a 404 not found.

This is not a surprise as the Endpoint’s documentation tells us the url config is about how URLs are generated, it is not the base path/prefix/scope for all the routes in your app.

So this config won’t make your routes to be found, unless you manually prefix them with /myapp. As per previous posts, this was not an option because the prefix is a runtime/deployment concern, but I temporarily accepted it as a workaround just to move on. So now the page and assets (and sockets added via mix phx.gen.socket and manually prefixed) are found, great, but not the live reload frame (http://localhost:4000/myapp/phoenix/live_reload/frame). And for that, it seems there is no config to be changed.

Also, Phoenix’s dashboard generates URLs with a duplicated /myapp, like http://localhost:4000/myapp/myapp/dashboard/home

That’s why I told you

Because

1 Like

I see. IMHO you have 2 choices:

  • make myapp part of your official route
  • install a sane reverse proxy such as nginx in front of your app

The solution I present in the proof of concept I linked above can also be used, and it was inspired in an Elixir proxy repo I found on my research at the time.

Replace below /_links/ with /myapp and remove /_notes an it should work for you, provided that you also follow the other bits in the step-by-step on the repo.

I have updated the Github repo with a more recent branch that I only had in Gitlab.:

Great discussion. Just if this helps:

My context:

  • In production I have my phoenix app dockerized and running behind a reverse proxy (nginx) configured on url “example.com/myapp”.
  • I want the app to be run in dev mode, deployed in production mode but also in production mode dockerized in my laptop (just to check everything will be working properly when deployed).
  • My final decision is to inject in the app.js the path ("/myapp" when deployed, "/" when dockerized in my laptop and nil in dev mode), the injection is done as the CSFR token is injected:
let liveSocketPath = document.querySelector("meta[name='live-socket-path']")?.getAttribute("content") || "/live"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket(liveSocketPath, Socket, {params: {_csrf_token: csrfToken}})

Then, data is extracted from the environment in the layout:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="live-socket-path" content={Application.get_env(:myapp, :phx_path)} />
    <meta name="csrf-token" content={get_csrf_token()} />
...
  • phx_path is a key in my configuration files, not defined in dev.ex and loaded from the system environment in runtime.ex

What do you think about the solution?

I found a better solution using the function MyAppWeb.Endpoint.path/1 to avoid introducing a new config entry:

    <meta name="live-socket-path" content={MyAppWeb.Endpoint.path "/live"} />

And let JS fail of the meta data is not defined:

let liveSocketPath = document.querySelector("meta[name='live-socket-path']").getAttribute("content");
3 Likes