Remote task runner using Erlang distribution over SSH. Zero dependencies.
Fusion connects to remote servers via SSH, sets up port tunnels for Erlang distribution, bootstraps a remote BEAM node, and lets you run Elixir code on it. Think Ansible/Chef but for Elixir - push modules and execute functions on remote machines without pre-installing your application.
- Running Elixir on Remote Servers with Fusion
- How Fusion Works: Tunnels and Distribution
- How Fusion Works: Bytecode Pushing
- Elixir ~> 1.18 / OTP 28+
- Remote server with Elixir/Erlang installed
- SSH access (key-based or password)
Add fusion to your list of dependencies in mix.exs:
def deps do
[
{:fusion, "~> 0.3.0"}
]
endYour local BEAM must be started as a distributed node:
iex --sname myapp@localhost -S mixThen connect and run code remotely:
# Define the target
target = %Fusion.Target{
host: "10.0.1.5",
port: 22,
username: "deploy",
auth: {:key, "~/.ssh/id_ed25519"}
}
# Connect (sets up tunnels, bootstraps remote BEAM, joins cluster)
{:ok, manager} = Fusion.NodeManager.start_link(target)
{:ok, remote_node} = Fusion.NodeManager.connect(manager)Run functions on the remote:
# Get remote system info
{:ok, version} = Fusion.run(remote_node, System, :version, [])
{:ok, {hostname, 0}} = Fusion.run(remote_node, System, :cmd, ["hostname", []])Run anonymous functions directly:
{:ok, info} = Fusion.run_fun(remote_node, fn ->
%{
node: Node.self(),
otp: System.otp_release(),
os: :os.type()
}
end)Push and run your own modules — dependencies are resolved automatically:
defmodule RemoteHealth do
def check do
%{
hostname: hostname(),
elixir_version: System.version(),
memory_mb: memory_mb()
}
end
defp hostname do
{name, _} = System.cmd("hostname", [])
String.trim(name)
end
defp memory_mb do
{meminfo, _} = System.cmd("cat", ["/proc/meminfo"])
meminfo
|> String.split("\n")
|> Enum.find(&String.starts_with?(&1, "MemTotal"))
|> String.split(~r/\s+/)
|> Enum.at(1)
|> String.to_integer()
|> div(1024)
end
end
{:ok, health} = Fusion.run(remote_node, RemoteHealth, :check, [])
# => %{hostname: "web-01", elixir_version: "1.18.4", memory_mb: 7982}Disconnect when done:
Fusion.NodeManager.disconnect(manager)Fusion uses Erlang's built-in SSH module by default. No system ssh binary required.
To use the legacy system SSH backend instead:
target = %Fusion.Target{
host: "10.0.1.5",
username: "deploy",
auth: {:key, "~/.ssh/id_ed25519"},
ssh_backend: Fusion.SshBackend.System # uses system ssh/sshpass
}When you run RemoteHealth remotely, Fusion reads the BEAM bytecode, walks the dependency tree, and pushes everything the module needs. You don't need to manually track the dependency chain.
# You can also push modules explicitly
Fusion.TaskRunner.push_module(remote_node, MyApp.Worker)
Fusion.TaskRunner.push_modules(remote_node, [MyApp.Config, MyApp.Utils])Standard library modules (Kernel, Enum, String, etc.) are already on the remote and don't need pushing.
Fusion creates 3 SSH tunnels between local and remote:
Local Machine Remote Server
───────────── ─────────────
┌─── Reverse ────┐
Local node port ◄┘ tunnel #1 └── Remote can reach local node
┌─── Forward ────┐
localhost:port ──┘ tunnel #2 └► Remote node's dist port
┌─── Reverse ────┐
Local EPMD ◄─┘ tunnel #3 └── Remote registers with local EPMD
(port 4369)
Starts Elixir on the remote via SSH with carefully configured flags:
ERL_EPMD_PORT=<tunneled>- routes EPMD registration through tunnel #3 back to local EPMD--sname worker@localhost- uses@localhostbecause all traffic goes through localhost-bound tunnels--cookie <local_cookie>- matches the local cluster's cookie--erl "-kernel inet_dist_listen_min/max <port>"- pins distribution port to match tunnel #2
Since the remote registered with the local EPMD, Node.connect/1 works as if the remote node were local. All distribution traffic is routed through the SSH tunnels.
Module bytecode is transferred via Erlang distribution:
- Read
.beambinary locally with:code.get_object_code/1 - Parse BEAM atoms table to find non-stdlib dependencies
- Push each dependency recursively (bottom-up)
- Load on remote with
:code.load_binary/3 - Execute via
:erpc.call/4
# Unit tests (no external dependencies)
mix test
# Docker integration tests (requires Docker)
cd test/docker && ./run.sh start
elixir --sname fusion_test@localhost -S mix test --include external
# Stop the test container
cd test/docker && ./run.sh stop- Tier 1 (Unit) - Doctests and pure logic tests. No network, no SSH.
- Tier 2 (Integration) - Tests against localhost SSH. Skips gracefully if not configured.
- Tier 3 (External) - End-to-end tests against a Docker container with SSH + Elixir. Requires
./run.sh start.
Fusion (public API)
├── TaskRunner - Remote code execution + module pushing + dependency resolution
├── NodeManager - GenServer: tunnel setup, BEAM bootstrap, connection lifecycle
├── Target - SSH connection configuration struct (includes ssh_backend selection)
├── SshBackend - Behaviour for pluggable SSH implementations
│ ├── Erlang - Default: uses OTP's built-in :ssh module
│ └── System - Legacy: shells out to system ssh/sshpass binaries
├── SshKeyProvider - Custom ssh_client_key_api for specific key file paths
├── TunnelSupervisor - DynamicSupervisor for tunnel processes
├── Net - Port generation, EPMD utilities
├── Connector - SSH connection GenServer
├── SshPortTunnel - SSH port tunnel process wrapper
├── PortRelay - Port relay process wrapper
├── UdpTunnel - UDP tunnel process wrapper
└── Utilities
├── Ssh - SSH command string generation
├── Exec - OS process execution (Port/System.cmd)
├── Erl - Erlang CLI command builder
└── Bash/Socat/Netcat/Netstat/Telnet - CLI tool wrappers
MIT
