The first thing to set up: which nodes exist, and what each one is for.
This is the foundation every other page builds on. Read it first; then move on to Defining APIs.
The model: nodes and tags
A NebulaAPI cluster is a set of nodes — each one an Erlang VM with a unique name of the
form short@host:
api@api.example # short name: api
db@db.example # short name: db
worker@worker.exampleEvery node carries one or more tags — arbitrary atoms that describe what the node is
for: :db, :worker, :cache, :cluster, whatever fits your system. Tags are how you
route by capability instead of hard-coding machine names:
- multiple nodes can share a tag (
:dbon two machines = load distribution / replicas), - a node can carry several tags (one box that's both
:apiand:worker).
You declare the whole map once, and the compiler reads it to decide what code goes where.
The nodes option (required)
# config/config.exs
config :nebula_api,
nodes: [
"api@api.example": [:cluster, :api],
"db@db.example": [:cluster, :db],
"worker@worker.example": [:cluster, :worker]
]- Node name — an atom of the form
short@host(quote it because of the@). In selectors you may use either the full name or the short part before@. - Tags — a list of atoms. These are what
&tagselectors match.
Workers are not configured here — each app wires nebula_api_server() into its own
supervision tree, which discovers its defapi modules and starts the workers. See
Defining APIs → wiring the server.
Compile with the target node name
NebulaAPI decides routing at compile time, so the compiler has to know which node it is
building for — it reads node(). Compile each release with its own --name:
elixir --name api@api.example -S mix compile && mix release api
Forget --name and the build stops with a CompileError: node() would be
nonode@nohost, and a missing node name is distinct from an unknown one — so
allow_unknown_self_node does not cover it. (Opt into a nameless build with
allow_nonode_nohost.)
Build each release in its own stage:
# api release — compiled as node api@api.example
RUN elixir --name api@api.example -S mix compile && mix release api
# worker release — separate stage
RUN elixir --name worker@worker.example -S mix compile && mix release workerdev/test: self_node instead of --name
In dev and test you usually don't start the VM with --name. Tell the compiler which node
to pretend to be:
# config/dev.exs
config :nebula_api,
default_opts: [self_node: :"api@api.example"]In production, prefer the real --name (then node() is authoritative).
Boot-time node policy
Compile-time and runtime node names must agree: a release bakes its routing for the node it
was compiled as, so it must run as that node. The runtime node name comes from Mix
release's RELEASE_NODE + RELEASE_DISTRIBUTION=name
(docs). At boot
NebulaAPI.Server enforces the match:
- Running as exactly the compiled node → serves normally — the only case that starts workers.
- Running as anything else → refuses to boot, with an explicit message: a worker build
run as
api@host, a build compiled without--namerun under a real name, or a real build run asnonode@nohost. The escape hatchALLOW_RUNTIME_NEBULA_NODE_MISMATCH=1boots it instead as a generic node: no server, serves nothing, every call routes remote. - Compiled nameless and run as
nonode@nohost→ a generic, inert node — no escape hatch needed (it's running exactly as built; seeallow_nonode_nohost).
default_opts
Defaults applied by every use NebulaAPI:
| Key | Type | Meaning |
|---|---|---|
self_node | atom | The node to build for, when not starting the VM with --name (dev/test). |
max_concurrent_calls | positive integer or :infinity | Cap on how many calls a module's worker runs at once on this node; 1 gives strict serialization. |
default_timeout | positive integer (ms) | Inherited default remote-call timeout. |
A module's own use NebulaAPI, ... options override these.
max_concurrent_calls is a server-side cap: it bounds how many calls the module's worker
executes concurrently on the node serving them, not how many a caller may have in flight.
Calls over the cap queue on the worker until a slot frees — but the queue entry is tied to
the caller's lifetime, so if a queued call times out (or the caller goes away) before a slot
opens, it is dropped and may never run. :infinity (the default) means no cap.
config :nebula_api,
default_opts: [self_node: :"api@api.example", default_timeout: 10_000]default_timeout
Global default timeout (ms) for remote calls. Resolution order, most specific first:
a call's own timeout: > the module's default_timeout: > this > 5000config :nebula_api, default_timeout: 15_000nodes_info_refresh_interval
How often (ms, default 5000) each node's background NodesInfoCache rebuilds the
cluster node-info snapshot served to selector functions (see
Calling across nodes → node info). Raise it
on larger clusters or when selectors tolerate staler data; until the first refresh, nodes
not yet in the snapshot are still offered to selectors with runtime: nil.
config :nebula_api, nodes_info_refresh_interval: 10_000allow_nonode_nohost
false by default. Set it to true and nonode@nohost is registered as an empty, tagless
node, so a release compiled without --name builds (its self_node is admitted). Such a
build runs as nonode@nohost: nebula_api_server() is a no-op (no workers, a boot warning),
it serves nothing, and every defapi call routes remote. nonode@nohost isn't distributed,
so it's inert — out of cluster. It's a generic node; see
the boot-time node policy and the README's
Generic nodes.
You may not list nonode@nohost in nodes yourself — it's the reserved generic identity
and can't carry tags (doing so raises). This flag is the only way to admit it, always empty.
config :nebula_api, allow_nonode_nohost: trueAdding a node or a tag
Just edit the nodes list — add a node, or add a tag to an existing one:
config :nebula_api,
nodes: [
"api@api.example": [:cluster, :api],
"db@db.example": [:cluster, :db, :reporting], # new :reporting tag
"worker@worker.example": [:cluster, :worker],
"cache@cache.example": [:cluster, :cache] # new node
]Then use the new tag/node in a selector: defapi &reporting, ... or defapi @cache, ....
Because routing is decided at compile time, a brand-new tag or node name means a
recompile — but bringing more instances of an existing role online needs nothing but
starting them.
If you cluster with libcluster, remember to add the node to your topology too — NebulaAPI only decides what code goes where; forming the Erlang cluster (epmd, DNS, libcluster, …) is the consumer's concern.
Reading configuration
NebulaAPI.Config.nodes() # => ["api@api.example": [:cluster, :api], ...]
NebulaAPI.Config.default_opts()
Application.get_env(:nebula_api, :nodes)Compile-time validation
NebulaAPI catches topology mistakes when you build, not when you ship.
Unknown node
defapi @nope, f() do ... endUnknown nodes in defapi call :
- @nope
Available nodes :
- @api
- @:"api@api.example"
- @db
- @:"db@db.example"
- @worker
- @:"worker@worker.example"Unknown tag
defapi &nope, f() do ... endUnknown tags in defapi call :
- &nope
Available tags :
- &api
- &cluster
- &db
- &workerUnknown self_node
If you compile on a node not in nodes (and didn't set allow_unknown_self_node: true),
use NebulaAPI raises a CompileError telling you the self_node it saw and the
configured nodes.
allow_unknown_self_node is a per-module use option, not a config key — set it on the
module being compiled (use NebulaAPI, allow_unknown_self_node: true), for throwaway compiles
where the building node legitimately isn't part of the topology. It does not silence a
missing node name (nonode@nohost), which is a separate case — see
No node name and allow_nonode_nohost.
No node name
If you compile without --name (so node() is nonode@nohost) and haven't set
allow_nonode_nohost: true, use NebulaAPI raises a CompileError:
the name isn't unknown, it's unset, so allow_unknown_self_node does not silence it.
It's almost always a forgotten --name. See
Compile with the target node name.
Testing
Give your test node every tag, so all defapi bodies compile as local and your tests run
without a cluster:
# config/test.exs
config :nebula_api,
nodes: ["test@localhost": [:cluster, :api, :db, :worker]],
default_opts: [self_node: :"test@localhost"]Next
- Defining APIs — write
defapiendpoints and wire the server. - Calling across nodes — call them, and override routing at runtime.