Phoenix apps can cluster themselves across multiple machines without you lifting a finger, provided you set up a few things correctly.
Let’s get a Phoenix app running on Fly.io, and make sure it can cluster.
First, you need a Phoenix app. If you don’t have one, mix phx.new my_app --database postgres --live will get you started. For this guide, we’ll assume you have a basic Phoenix app ready to deploy.
Next, you need to set up Fly.io. If you haven’t already, install the Fly.io CLI: curl -L https://fly.io/install.sh | sh. Then, log in: fly auth login.
Now, we need to create a fly.toml file in your project’s root directory. This file tells Fly.io how to build and deploy your application.
app = "my-phoenix-app-unique" # Replace with your desired app name
primary_region = "ord" # Or your preferred region
[build]
image = "elixir:1.15.7" # Use a recent Elixir version
dockerfile = "Dockerfile"
[deploy]
strategy = "rolling"
[[services]]
protocol = "tcp"
port = 80
handlers = ["http"]
[[services.concurrency]]
type = "connections"
hard_limit = 1000
soft_limit = 1000
[[services.ports]]
port = 8080 # Phoenix default internal port
[[services.tcp_checks]]
interval = "30s"
timeout = "4s"
We also need a Dockerfile. This will build your Elixir release.
# Use an official Elixir runtime as a parent image
FROM elixir:1.15.7
# Set the working directory in the container
WORKDIR /app
# Install Hex and rebar3
RUN mix local.hex --force && \
mix local.rebar --force
# Copy the project files into the container
COPY . .
# Build the Elixir release
RUN mix release --overwrite
# Expose the port the app runs on
EXPOSE 8080
# Set the entrypoint to the release's start script
ENTRYPOINT ["/app/bin/my_app", "start"]
Replace my_app in the ENTRYPOINT with the actual name of your Elixir application (the name used in your mix.exs alias or module definition).
Before deploying, we need to configure Elixir’s distributed computing features. The key here is Phoenix.PubSub or Phoenix.Presence needing a distributed backend. By default, they use an in-memory backend, which won’t work across multiple machines. We’ll use Rendezvous for this.
Add rendezvous to your mix.exs dependencies:
def deps do
[
{:phoenix, "~> 1.7.10"},
{:phoenix_html, "~> 4.0.5"},
{:phoenix_live_view, "~> 0.18.17"},
{:phoenix_live_reload, "~> 1.2.1", only: [:dev]},
{:phoenix_live_dashboard, "~> 0.8.2"},
{:esbuild, "~> 0.7.0"},
{:tailwind, "~> 0.2.0", render_with: [Phx.Component]},
{:telemetry_metrics, "~> 0.6.0"},
{:telemetry_poller, "~> 1.0.1"},
{:gettext, "~> 0.23.0"},
{:jason, "~> 1.4"},
{:plug_cowboy, "~> 2.6"},
{:rendezvous, "~> 0.3.0"} # Add this line
]
end
And run mix deps.get.
Now, configure rendezvous in your config/runtime.exs (or config/prod.exs if you’re not using runtime.exs):
import Config
# Configure Fly.io secrets
database_url =
System.get_env("DATABASE_URL") ||
raise "DATABASE_URL must be set"
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise "SECRET_KEY_BASE must be set"
config :my_app, MyApp.Repo,
url: database_url,
ssl: true,
pool_size: 10
config :my_app, MyAppWeb.Endpoint,
http: [ip: {0, 0, 0, 0}, port: 8080],
secret_key_base: secret_key_base
# Configure Rendezvous for clustering
config :rendezvous,
# Use the Fly.io internal networking for discovery
discovery: :fly_global,
# Set the name of the node (can be anything unique per VM)
node_name: {:via_tuple, {:"@fly_ipv4_", :inet_parse.string!(System.get_env("FLY_INTERNAL_IP"))}},
# Set the cookie for inter-node communication
cookie: System.get_env("ERLANG_COOKIE") || "changeme",
# Enable distributed PubSub
pubsub_backend: Phoenix.PubSub.Rendezvous
# If you use Phoenix.Presence, configure it similarly:
# config :phoenix_presence, :store, Phoenix.Presence.Rendezvous
Make sure to set ERLANG_COOKIE as a Fly.io secret. This cookie must be the same across all your deployed instances for them to form a cluster.
Now, let’s set up the database. Fly.io provides managed PostgreSQL. Create a new database: fly pg create --name my-phoenix-app-db-unique --region ord. Once created, add its connection string as a Fly.io secret: fly secrets set DATABASE_URL='postgres://user:password@host:port/database?sslmode=disable'.
Also, set the SECRET_KEY_BASE. You can generate one with mix phx.gen.secret. Then set it as a secret: fly secrets set SECRET_KEY_BASE='your_generated_secret_key_base'.
And the ERLANG_COOKIE: fly secrets set ERLANG_COOKIE='a_strong_random_cookie_here'.
Now, you can deploy: fly deploy.
Once deployed, Fly.io assigns an internal IP address to each VM. Rendezvous uses this. To see your app running, you can fly open.
To verify clustering, you can scale your app: fly scale count 2. Then, in your application’s LiveView or controller, you can send a message to all connected nodes. For instance, in a LiveView:
def handle_info({:broadcast, msg}, socket) do
IO.puts("Received broadcast: #{inspect(msg)}")
{:noreply, socket}
end
def mount(_params, _session, socket) do
# Subscribe to a topic
Phoenix.PubSub.subscribe(MyApp.PubSub, "my_topic")
{:ok, socket}
end
# In a button click handler or similar:
def handle_event("send_message", _, socket) do
MyApp.PubSub.broadcast(MyApp.PubSub, "my_topic", {:hello, "from_node_#{Node.self()}"})
{:noreply, socket}
end
When you click the button that triggers send_message on one VM, all other running VMs that have subscribed to my_topic will receive the {:hello, ...} message. You can check the logs of your other VMs using fly logs.
The most surprising thing about Elixir’s distributed capabilities is how seamlessly they integrate with the language’s core concurrency primitives, making complex distributed systems feel almost like writing regular Elixir code. The Node module provides direct access to inter-process communication, and libraries like Rendezvous abstract away the complexities of discovery and connection management in cloud environments.
The node_name configuration for rendezvous is particularly clever. By using {:via_tuple, {:"@fly_ipv4_", :inet_parse.string!(System.get_env("FLY_INTERNAL_IP"))}}, we dynamically create a unique node name for each VM based on its internal Fly.io IP address. This ensures that each instance is recognized as a distinct, but connectable, node within the Erlang cluster. The FLY_INTERNAL_IP environment variable is automatically provided by Fly.io to each VM.
Once your app is clustered and stable, the next natural step is to explore advanced deployment strategies like blue-green deployments or canary releases on Fly.io to minimize downtime during updates.