The BEAM and the Crab: Building Tunnels

Adrián Carayol

December 29, 2025

Abstract

A deep dive into building an ngrok-like tunneling system using Elixir/Phoenix on the server and Rust for the CLI. We explore the challenges of multiplexing HTTP requests over a single WebSocket connection, the elegance of Phoenix Channels, and how the BEAM's real-time capabilities make this architecture surprisingly simple.


Introduction

We’ve all used ngrok at some point to expose our REST API to a coworker or to receive webhook events locally, such as Stripe events.

In recent days, I decided to add this feature to HookListener: the ability to create tunnels to expose your localhost to the Internet.

You might wonder, why? For several reasons, but mainly because I wanted to develop this feature and because having something like this in Hooklistener further justifies the paid plans (for static domains, dynamic domains are free :D)

Let’s dive into how it all works.

High-Level Architecture

The tunneling system has two main components: a server written in Elixir/Phoenix, and a CLI written in Rust.

Here’s the flow: when you start the CLI, it establishes a WebSocket connection to our Phoenix server. The server assigns a unique subdomain (e.g., abc123.hook.events) and registers the tunnel. From that moment, any HTTP request hitting that subdomain gets forwarded through the WebSocket to your CLI, which proxies it to your local service and sends the response back.

HookListener Architecture

On the infrastructure side, we use Hetzner for servers, Cloudflare as a proxy and load balancer, and Kamal with GitHub Actions for deployment. We also use libcluster_postgres to cluster our Elixir application, enabling communication between nodes.

The CLI uses ratatui for the TUI and tokio for async runtime.

The Protocol: Solving the Multiplexing Challenge

A single WebSocket connection needs to handle multiple concurrent HTTP requests. This raises three challenges:

  1. Reconnections - What happens when the client disconnects, we deploy, or a process crashes?
  2. Multiplexing - How do we handle multiple requests over a single connection without blocking?
  3. Request-Response correlation - How do we know which response belongs to which request?

Handling Reconnections

The CLI maintains a heartbeat to detect connection loss. When disconnected, it automatically reconnects and re-registers the tunnel. On the server side, Elixir supervisors handle process crashes, restarting them as needed.

Request-Response Correlation

Each incoming HTTP request gets a unique ID stored in an ETS table. The flow is:

  1. Request arrives at the server and gets assigned a unique ID.
  2. The request (with its ID) is sent to the CLI through the Phoenix Channel.
  3. The CLI forwards it to your local service, receives the response, and sends it back with the same request_id.
  4. The server looks up the ETS table, finds the waiting HTTP process, and delivers the response.

Multiplexing Without Blocking

How do we ensure a large file doesn’t slow down the rest of your site? Instead of sequential processing that creates a bottleneck, our system is fully multiplexed, allowing multiple data streams to flow at the same time

Parallel Request Timeline

Serialization

Everything sent through the Phoenix Channel is JSON. For binary content like images, we encode them in base64.

The Server: Elixir & Phoenix

Tunnels in HookListener use a dedicated domain (hook.events), separate from the main application. A simple Plug intercepts incoming requests and routes them to the tunnel controller based on the host header.

Why Phoenix Channels?

Phoenix Channels provide exactly what we need out of the box:

The Channel subscribes to PubSub topics, allowing the Tunnel Controller to broadcast requests to the appropriate client.

The Tunnel Registry

When a new tunnel is created, it’s registered in our TunnelRegistry, an ETS table mapping tunnel slugs to Channel PIDs. We monitor each Channel process with Process.monitor to detect crashes.

Handling Crashes

What happens when a tunnel crashes? The system is designed to fail gracefully:

  1. Channel process dies → TunnelRegistry receives a :DOWN message via Process.monitor.
  2. Registry cleans up the dead connection.
  3. Pending HTTP requests receive a 502 error (tunnel disconnected).
  4. CLI reconnects → new Channel process → new registry entry.
  5. The server keeps running—no cascade failures.

The Client: Rust

When I decided to build a CLI for HookListener, I considered several options, Go with Bubbletea being the main contender, since I had prior experience with that stack. However, I gave ratatui a try, and it impressed me: excellent performance, polished API, and everything just worked. So Rust it was.

The CLI uses tokio for the async runtime and tokio-tungstenite for WebSocket connections.

Speaking Phoenix from Rust

The CLI needs to speak the Phoenix Channel protocol over WebSocket. Every message follows this structure:

{
  "topic": "tunnel:connect",
  "event": "phx_join",
  "payload": { "local_port": 3000 },
  "ref": "1"
}

The connection flow:

  1. Connect — Open a WebSocket to wss://server/socket/websocket?token=<auth_token>
  2. Join — Send a phx_join event to the tunnel:connect topic
  3. Receive confirmation — Server replies with the assigned subdomain and tunnel ID
  4. Heartbeat — Send a heartbeat every 30 seconds to keep the connection alive

The Request-Response Loop

Once connected, the CLI listens for tunnel_request events. Each request contains:

The forwarding flow:

  1. Parse the incoming tunnel_request
  2. Build an HTTP request using reqwest
  3. Forward it to localhost:PORT
  4. Capture the response (status, headers, body)
  5. Send a tunnel_response back with the same request_id

For binary responses (images, fonts, etc.), we detect non-UTF-8 content and encode it as base64 before sending.

Real-Time Visibility

The TUI, built with ratatui, displays live information: connection status, the public tunnel URL, request statistics, and a scrollable table of all requests with their method, path, status code, and response time. This is powered by tokio channels, the tunnel task sends events, and the UI receives them without blocking.

Conclusion

I had a lot of fun building this feature. Claude Code was a huge help, especially for the CLI. I didn’t have much prior experience with Rust.

Elixir and Phoenix were also invaluable. The BEAM is incredible for systems that require real-time capabilities. In other ecosystems, building something like Phoenix Channels from scratch would have been a massive undertaking.

Beyond the tech, one thing I really appreciate is the simplicity of scaling and deploying the application. A single repository, no context-switching between backend and frontend, one application to monitor and deploy. As a solo developer, this matters a lot. Don’t spend money on complex infrastructure, and try to stay as independent as possible—avoid locking yourself into any single provider, because costs tend to skyrocket once your app gets even a little traffic.