SSH Deployment
View SourceServe Raxol apps over SSH. Each connection gets its own process: one app, many users.
Erlang ships :ssh in the standard library, so this is a wrapper rather than a server: no extra dependency, no separate daemon.
Quick start
Any TEA app can be served over SSH. Authentication is required unless anonymous access is explicitly requested, so a fund-bearing surface is never silently anonymous:
# Public, read-only app (a dashboard, a component catalog): anonymous is fine.
Raxol.SSH.serve(MyApp, port: 2222, allow_anonymous: true)
# A surface that can reach payment Actions: require a public key.
Raxol.SSH.serve(MyApp, port: 2222, authorized_keys_dir: "/etc/raxol/authorized")With neither option the server refuses to start. See Authentication below.
Connect from any machine:
ssh localhost -p 2222
No client-side dependencies. Any SSH client works: PuTTY, OpenSSH, even ssh from a phone.
Full example
# lib/my_ssh_app.exs
defmodule MySshApp do
use Raxol.Core.Runtime.Application
@impl true
def init(_ctx), do: %{count: 0}
@impl true
def update(msg, model) do
case msg do
:increment -> {%{model | count: model.count + 1}, []}
:decrement -> {%{model | count: model.count - 1}, []}
%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "q"}} -> {model, [Directive.stop()]}
%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "="}} -> update(:increment, model)
%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "-"}} -> update(:decrement, model)
_ -> {model, []}
end
end
@impl true
def view(model) do
column style: %{padding: 1, align_items: :center} do
[
text("SSH Counter", fg: :cyan, style: [:bold]),
text("Count: #{model.count}", style: [:bold]),
row style: %{gap: 1} do
[button("=", on_click: :increment), button("-", on_click: :decrement)]
end,
text("Press q to disconnect", fg: :magenta)
]
end
end
@impl true
def subscribe(_model), do: []
end
# Start SSH server (a public counter demo, so anonymous access is intended)
{:ok, _} = Raxol.SSH.serve(MySshApp, port: 2222, allow_anonymous: true)
# Keep alive
Process.sleep(:infinity)Run it:
mix run lib/my_ssh_app.exs
This is a simplified version of examples/ssh/ssh_counter.exs.
How it works
SSH Client ---> :ssh.daemon (Erlang)
|
+--> CLIHandler (SSH protocol)
|
+--> Session (per-connection)
|
+--> Lifecycle (TEA loop)
|
+--> Your App- Erlang's built-in
:sshmodule handles the SSH protocol - The SSH CLI handler translates SSH channel events to Raxol events
- The SSH session manager creates a per-connection Lifecycle process
- Your app runs identically to local mode, with the same
init/update/view
Each connection is isolated. One user's crash doesn't affect others.
Configuration
Authentication
A surface that can reach payment Actions must not be silently anonymous, so authentication is fail-closed: pass one of two options, or the server refuses to start.
allow_anonymous: trueaccepts any connection. Use it for a public, read-only app (a dashboard, the component playground).authorized_keys_dir: "/path"requires public-key auth. The directory holds anauthorized_keysfile listing the permitted public keys, and a connection must present a listed key.
For any surface that can move funds, use authorized_keys_dir and bind the connection to that identity before it reaches a payment Action. Do not rely on an in-app login screen inside an otherwise-anonymous SSH session, which would put credential handling inside your update/2 instead of the transport.
Port and host keys
Raxol.SSH.serve(MyApp,
port: 3000,
host_keys_dir: "/etc/raxol/ssh_keys", # default: ~/.raxol/ssh_keys
allow_anonymous: true
)Host keys are auto-generated on first run under a persistent per-user directory (~/.raxol/ssh_keys), so clients do not get host-key-changed warnings on restart. Point host_keys_dir at a directory your service account owns to keep the key stable across deploys.
Running alongside a Phoenix app
Add the SSH server to your supervision tree:
# lib/my_app/application.ex
def start(_type, _args) do
children = [
MyAppWeb.Endpoint,
{Raxol.SSH.Server,
app_module: MyTerminalApp,
port: 2222,
authorized_keys_dir: "/etc/raxol/authorized"}
]
Supervisor.start_link(children, strategy: :one_for_one)
endNow the same app runs in the browser (via LiveView) and over SSH simultaneously.
Production Considerations
Persistent host keys
Generate keys once and store them:
mkdir -p /etc/raxol/ssh_keys
ssh-keygen -t rsa -f /etc/raxol/ssh_keys/ssh_host_rsa_key -N ""
ssh-keygen -t ecdsa -f /etc/raxol/ssh_keys/ssh_host_ecdsa_key -N ""
Isolation from signing
Do not co-locate an SSH surface with a node that holds signing keys. The interactive REPL and any anonymous surface are capability-escape risks next to a wallet, so keep the payment node separate. On the signing node, call Raxol.Payments.Deployment.assert_signing_isolated!/0 at boot: it refuses to start when RAXOL_REPL_EXPOSED=true. If the SSH box joins an Erlang cluster with the signing node, run distribution over TLS (-proto_dist inet_tls with per-node certs), never a shared magic cookie, and gate it with Raxol.Payments.Deployment.assert_distribution_secure!/0.
Systemd service
[Unit]
Description=Raxol SSH App
After=network.target
[Service]
Type=simple
User=raxol
ExecStart=/usr/local/bin/mix run --no-halt
WorkingDirectory=/opt/my_app
Environment=MIX_ENV=prod
Restart=always
[Install]
WantedBy=multi-user.targetFly.io
Expose the SSH port in fly.toml:
[[services]]
internal_port = 2222
protocol = "tcp"
[[services.ports]]
port = 2222Then connect:
ssh your-app.fly.dev -p 2222
Use Cases
SSH beats web dashboards when you want zero client setup: no HTTPS certs, no browser, works over slow networks, instant startup. Same init/update/view whether local, over SSH, or in a browser.
- Shared dashboards: Deploy a monitoring dashboard. Anyone with SSH access can view it.
- Remote admin tools: Database inspection, log viewers, config editors, all in the terminal.
- Pair programming: Multiple users connected to the same app. Each sees independent state (or share state via PubSub).
- IoT/embedded: Run on a Raspberry Pi. SSH in from anywhere to check sensor readings.
- Bastion host UIs: Replace clunky web admin panels with fast terminal interfaces.
Next steps
- Architecture: how the render pipeline works
- Cookbook index: the other recipes