View Source Deployment Topologies

The routing system in Phoenix combined with the OTP distribution model opens many possibilities for deployment strategies and in this guide we'll cover the most common ones.

Code examples might be abbreviated and infrastructure details like load balancers are not covered in this guide to keep it short.

Clustering your application is also not covered in this guide but you can find the documentation on the platform's site you're using, for example https://fly.io/docs/elixir/the-basics/clustering and https://www.gigalixir.com/docs/cluster.

Core Concepts

Firstly, we need to understand a few core concepts:

URL Generation

The URL of a Beacon Page inside a Phoenix Router scope is generated by the combination of the Endpoint and the Router configuration as endpoint host + scope path + site prefix + page path, for example:

# endpoint
config :my_app, MyAppWeb.Endpoint, url: [host: "mysite.com"]

# router scope
scope "/2024" do
  beacon_site "/campaigns"
end

# beacon page
%Beacon.Content.Page{path: "christmas"}

Results in:

http://mysite.com/2024/campaigns/christmas`
^                 ^    ^         ^
|                 |    |         |
endpoint          |    |         page path
                  |    site prefix
                  scope path

See Beacon.Router and Phoenix.Router for added context.

URL Matching

A common technique to host multiple sites (hosts) in the same application is matching the :host in the scope:

# only matches requests from sitea.com
scope host: "sitea.com" do
  # ...
end

# only matches requests from siteb.com
scope host: "siteb.com" do
  # ...
end

The downside of having such flexibility is creating a configuration that is either invalid or not optimized. Take for instance this configuration:

scope host: "sitea.com" do
  beacon_site "/", site: :site_a
end

scope host: "siteb.com" do
  beacon_site "/", site: :site_b
end

Note that :site_b will never match when deployed to sitea.com and :site_a will never match on siteb.com. You can already tell that starting :site_b in the node that is hosting sitea.com is a complete waste of resources since it will never match any request, which is not a big problem when you have a couple of small sites, but that becomes a problem as your environment grows.

To avoid this problem, Beacon will selectively boot only the sites that are reachable in the current host, so in the example above, only :site_a will be booted in the node hosting sitea.com and only :site_b in the node hosting siteb.com. This behavior also applies to route conflicts as well, for example:

scope do
  get "/", PageController, :hello
  beacon_site "/", site: :site_a
end

Two routes in the same prefix will cause a conflict.

Or this other example:

scope do
  beacon_site "/", site: :main_site
  beacon_site "/campaigns", site: :campaigns_site
end

The macro beacon_site creates a catch-all route /* so the second site will never be reached since /campaigns is a valid route for the first site.

Those might look obvious but that's a common source of confusion, especially in long and more complex router files. So Beacon won't try to boot sites that can't be reached, but a warning will be displayed.

Admin Sites Discovery

BeaconLiveAdmin is designed to scan the all apps connected in the same cluster to find running sites and make them available in the admin interface as displayed below:

graph TD
    subgraph Cluster
        subgraph ProjectA[Project A]
            site_a[site_a]
            project_a_site_b[site_b]
        end
        subgraph ProjectB[Project B]
            site_b[site_b]
            site_c[site_c]
        end
        subgraph Admin[Admin]
            admin_site_a[site_a]
            admin_site_b[site_b]
            admin_siet_c[site_c]
        end

        site_a --> admin_site_a
        site_b --> admin_site_b
        project_a_site_b --> admin_site_b
        site_c --> admin_siet_c
    end

The most important word here is "running" sites. Suppose :site_a in the node Project A is not running, either due to a route conflict, invalid configuration, or any other reason, then Admin won't find it and it won't be available in the admin interface:

graph TD
    subgraph Cluster
        subgraph ProjectA[Project A]
            project_a_site_b[site_b]
        end
        subgraph ProjectB[Project B]
            site_b[site_b]
            site_c[site_c]
        end
        subgraph Admin[Admin]
            admin_site_b[site_b]
            admin_siet_c[site_c]
        end

        site_b --> admin_site_b
        project_a_site_b --> admin_site_b
        site_c --> admin_siet_c
    end

With these constraints in mind, let's check some deployment strategies.

Strategies

Single application

The most simple strategy is a single project with one or more sites and the admin interface in the same project.

# endpoint
config :my_app, MyAppWeb.Endpoint, url: [host: "mysite.com"]

# router
scope "/" do
  beacon_live_admin "/admin"
  beacon_site "/campaigns", site: :campaigns
  beacon_site "/", site: :root
end
flowchart TD
    subgraph Node1["Node1"]
        site_a["/campaigns, site: :campaigns"]
        site_b["/, site: :root"]
        subgraph Admin["/admin"]
          admin_site_a[":campaigns"]
          admin_site_b[":root"]
        end
    end
    subgraph Cluster["Cluster"]
        Node1
        Admin
    end

    site_a --> admin_site_a
    site_b --> admin_site_b
    r1["mysite.com/campaigns/christmas"] --> site_a
    r2["mysite.com/contact"] --> site_b
    r3["mysite.com/admin"] --> Admin

Clustered single applications

Same project as the previous strategy but with multiple nodes deployed in the same cluster.

flowchart TD
    subgraph Node1["Node1"]
        n1_site_a["/campaigns, site: :campaigns"]
        n1_site_b["/, site: :root"]
        subgraph Node1Admin["/admin"]
          n1_admin_site_a[":campaigns"]
          n1_admin_site_b[":root"]
        end
    end
    subgraph Node2["Node2"]
        n2_site_a["/campaigns, site: :campaigns"]
        n2_site_b["/, site: :root"]
        subgraph Node2Admin["/admin"]
          n2_admin_site_a[":campaigns"]
          n2_admin_site_b[":root"]
        end
    end
    subgraph Cluster["Cluster"]
        Node1
        Node2
    end

    n1_site_a --> n1_admin_site_a
    n1_site_b --> n1_admin_site_b
    n2_site_a --> n2_admin_site_a
    n2_site_b --> n2_admin_site_b
    r1["mysite.com/campaigns/christmas"] --> n1_site_a
    r1["mysite.com/campaigns/christmas"] --> n2_site_a
    r2["mysite.com/contact"] --> n1_site_b
    r2["mysite.com/contact"] --> n2_site_b
    r3["mysite.com/admin"] --> Node1Admin
    r3["mysite.com/admin"] --> Node2Admin

Clustered applications with separated admin

You can observe the previous strategy duplicates the admin interface in each node, which works fairly well when you have no more than a couple of sites, but that setup tends to become harder to manage and also become a waste of resources if you start booting more site and more nodes.

So an optimization is to move the Admin interface into its own project and node (a new Phoenix project), and keep the sites in their own projects.

Note that in order to Admin find the sites, all the apps must be connected in the same cluster.

# endpoint
config :my_app, MyAppWeb.Endpoint, url: [host: "admin.mysite.com"]

# router
scope "/" do
  beacon_live_admin "/"
end
flowchart TD
    subgraph Node1["NodeApp1"]
        n1_site_a["/campaigns, site: :campaigns"]
        n1_site_b["/, site: :root"]
    end
    subgraph Node2["NodeApp2"]
        n2_site_a["/campaigns, site: :campaigns"]
        n2_site_b["/, site: :root"]
    end
    subgraph NodeAdmin["NodeAdmin"]
        admin_site_a[":campaigns"]
        admin_site_b[":root"]
    end
    subgraph Cluster["Cluster"]
        Node1
        Node2
        NodeAdmin
    end

    n1_site_a --> admin_site_a
    n1_site_b --> admin_site_b
    n2_site_a --> admin_site_a
    n2_site_b --> admin_site_b
    r1["mysite.com/campaigns/christmas"] --> n1_site_a
    r1["mysite.com/campaigns/christmas"] --> n2_site_a
    r2["mysite.com/contact"] --> n1_site_b
    r2["mysite.com/contact"] --> n2_site_b
    r3["admin.mysite.com"] --> NodeAdmin

A huge benefit of this topology is the flexibility to protect the Admin interface behind a VPN or scale it independently from the main applications.

Multiple hosts in single project, separated hosting apps

Still a single project but now serving multiple sites at the root path for different dynamic hosts.

In this example let's assume the most simple scenario where you'd create a new app in the hosting platform to serve each domain:

  • App1 -> mysite.com
  • App2 -> campaigns.mysite.com

That means isolated apps not connected to each other, just sharing the same codebase.

# endpoint
host = System.get_env("PHX_HOST")
config :my_app, MyAppWeb.Endpoint, url: [host: host]

# router
scope "/", host: "campaigns.mysite.com" do
  beacon_live_admin "/admin"
  beacon_site "/", site: :campaigns
end

scope "/", host: "mysite.com" do
  beacon_live_admin "/admin"
  beacon_site "/", site: :root
end
flowchart TD
    subgraph Node1["Node1"]
        n1_site["/, site: :root"]
        subgraph Node1Admin["/admin"]
          n1_admin_site[":root"]
        end
    end
    subgraph Node2["Node1"]
        n2_site["/, site: :campaigns"]
        subgraph Node2Admin["/admin"]
          n2_admin_site[":campaigns"]
        end
    end
    subgraph App1["App mysite.com"]
      subgraph Cluster1["Cluster"]
          Node1
      end
    end
    subgraph App2["App campaigns.mysite.com"]
      subgraph Cluster2["Cluster"]
          Node2
      end
    end

    n1_site --> n1_admin_site
    n2_site --> n2_admin_site
    r1["mysite.com/campaigns/christmas"] --> n2_site
    r2["mysite.com/contact"] --> n1_site
    r3["mysite.com/admin"] --> Node1Admin
    r4["campaigns.mysite.com/admin"] --> Node2Admin

Multiple hosts in single project, connected hosting apps

Similar setup as the previous strategy but now connecting the apps in the same cluster with a separated admin interface.

# endpoint
host = System.get_env("PHX_HOST")
config :my_app, MyAppWeb.Endpoint, url: [host: host]

# router
scope "/admin", host: "admin.mysite.com" do
  beacon_live_admin "/"
end

scope "/", host: "campaigns.mysite.com" do
  beacon_site "/", site: :campaigns
end

scope "/", host: "mysite.com" do
  beacon_site "/", site: :root
end
flowchart TD
    subgraph Node1["Node1"]
        n1_site["/, site: :root"]
    end
    subgraph Node2["Node2"]
        n2_site["/, site: :campaigns"]
    end
    subgraph Admin["NodeAdmin"]
        admin_site_a[":root"]
        admin_site_b[":campaigns"]
    end
    subgraph Cluster["Cluster"]
      subgraph App1["App1 mysite.com"]
          Node1
      end
      subgraph App2["App2 campaigns.mysite.com"]
          Node2
      end
      subgraph AppAdmin["App admin.mysite.com"]
          Admin
      end
    end

    n1_site --> admin_site_a
    n2_site --> admin_site_b
    r1["mysite.com/campaigns/christmas"] --> n2_site
    r2["mysite.com/contact"] --> n1_site
    r3["admin.mysite.com"] --> Admin