Fusion
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.
Articles
- Running Elixir on Remote Servers with Fusion
- How Fusion Works: Tunnels and Distribution
- How Fusion Works: Bytecode Pushing
Requirements
- Elixir ~> 1.18 / OTP 28+
- Remote server with Elixir/Erlang installed
- SSH access (key-based or password)
Installation
Add fusion to your list of dependencies in mix.exs:
def deps do
[
{:fusion, "~> 0.2.0"}
]
endUsage
Your 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)SSH Backend
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
}Automatic Dependency Resolution
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.
How It Works
1. SSH Tunnel Setup
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)2. Remote BEAM Bootstrap
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
3. Transparent Connection
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.
4. Code Pushing
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
Testing
# 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 stopTest Tiers
- 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.
Architecture
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 wrappersLicense
MIT