Skip to main content
Elixir and Erlang applications often need their nodes to find each other — to hand work to a peer, to run a distributed registry, or to broadcast over Phoenix PubSub. On LocalOps, you get there by adding one key to ops.json in each service that should be part of the cluster.
This page assumes your service is already deploying on LocalOps. If you’re still packaging your app, start with the Elixir/Phoenix guide.

Enable it

ops.json
Use the same value in every service you want clustered together — your web service, your worker, your internal RPC service. LocalOps then labels all their pods alike and creates a shared headless service whose DNS returns every member pod’s IP.

What LocalOps injects

Two environment variables are injected into your pods: app-services is the Kubernetes namespace all your services run in within the environment.

Wire up libcluster

Read the DNS name from the environment — don’t hardcode it.
config/runtime.exs
Always read LOPS_BEAM_CLUSTER_DNS at run time. The DNS name is derived from your beam_cluster_id and differs between your production environment and each preview environment, so a hardcoded value will silently fail to find peers somewhere.

Your release config stays yours

LocalOps does not set RELEASE_DISTRIBUTION, RELEASE_NODE, or RELEASE_COOKIE. Set these yourself — typically RELEASE_DISTRIBUTION=name with RELEASE_NODE=myapp@${POD_IP}. POD_IP is already injected into every service, so you can reference it directly.
Use the same RELEASE_COOKIE across every service in the cluster, or nodes will refuse each other. Add it as a secret with the same value in each service.

Cluster across regions

The headless service above only resolves inside one environment’s Kubernetes cluster, so nodes in your Mumbai environment can’t see nodes in Ireland. To form a single mesh across regional environments, give each environment a DNS name in a private zone that every regional VPC can resolve, and let LocalOps keep that name pointed at the environment’s pods.

Create one private zone shared by every regional VPC

On AWS, create a Route 53 private hosted zone — say mesh.acmecorp.internal — and associate it with the VPC of every regional environment you spin up. One zone, many VPC associations. If your environments live in different AWS accounts, use cross-account zone association. On GCP, the equivalent is a Cloud DNS private zone with each regional VPC added to its visibility list.The zone has to be associated with a region’s VPC before nodes there can resolve peers.

Give each environment a name in ops.json

Alongside beam_cluster_id, set beam_dns_name to the record this environment should own inside that zone:
ops.json
Keep beam_cluster_id identical everywhere — it’s still what makes these nodes one cluster. beam_dns_name is per environment: mumbai.mesh.acmecorp.internal in Mumbai, ireland.mesh.acmecorp.internal in Ireland, and so on.LocalOps creates that record and keeps it in sync — it publishes the IPs of every pod in the environment carrying that beam_cluster_id, and updates the record as pods scale, restart, or get rescheduled. You don’t manage the record set yourself.

List every region in your topologies

Add one topology per region so libcluster polls all of them. LOPS_BEAM_CLUSTER_DNS still points at the local headless service, so keep using it for this environment’s own nodes and name the peer regions by their beam_dns_name:
config/runtime.exs
The same config ships to every region — each node finds its local peers through LOPS_BEAM_CLUSTER_DNS and every remote region through that region’s record. Nodes connect from both ends, and the mesh forms.
DNS gets nodes to each other’s addresses; it doesn’t get packets there. The regional VPCs still need routable connectivity between them — VPC peering or a Transit Gateway — with EPMD (4369) and your distribution port range open between them. And the RELEASE_COOKIE has to match across regions, not just across services in one region.
Preview environments ignore beam_dns_name. They keep forming their own single-region cluster, for the same reason they stay out of your production cluster.

Things to know

It takes effect on your next build

LocalOps snapshots ops.json when it builds your service, so redeploying your current image won’t pick up a newly added beam_cluster_id. Push the change and let it build.

Preview environments get their own cluster

A preview environment forms its own separate cluster and never joins your production cluster. This is deliberate — otherwise a preview would share your cookie and be able to call into live nodes. See ephemeral databases and services for pull request previews.

Job and cron services join too

Job and cron services join the cluster as well, so scheduled and one-off tasks can call into the running cluster. If you’d rather they stayed out, just leave beam_cluster_id out of those services’ ops.json.
Two caveats when they are in. Each run is a join-then-leave, which is a cluster-wide event — :global locks the cluster on every nodeup, and Horde/Swarm rebalance their registries — so a frequent schedule means constant churn. And those pods join as full peers, able to run code on any other node.

Processes with their own image stay out

A processes entry that specifies its own image stays out of the cluster, since it isn’t running your app.

Verify it worked

Get a shell on a running pod (see Shell into a pod), open a remote console, and run:
It should return your peers. The cluster’s name is in LOPS_BEAM_CLUSTER_NAME. To watch inter-node distribution traffic over time, see the Erlang & Elixir instrumentation guide — the Erlang-Distribution Grafana dashboard it links is built for exactly this.