The BEAM and the Crab: Building Tunnels
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.
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:
- Reconnections - What happens when the client disconnects, we deploy, or a process crashes?
- Multiplexing - How do we handle multiple requests over a single connection without blocking?
- 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:
- Request arrives at the server and gets assigned a unique ID.
- The request (with its ID) is sent to the CLI through the Phoenix Channel.
- The CLI forwards it to your local service, receives the response, and sends it back with the same
request_id. - 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
- Each HTTP request is processed independently with its own timeout.
- Requests are broadcast through PubSub, which is non-blocking.
- The CLI can process requests concurrently and respond in any order.
- ETS tables support concurrent reads and writes.
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:
- Topic abstraction:
join("tunnel:connect"...) - Built-in PubSub integration
- Socket-level authentication via
connect/2 - Automatic heartbeats
- Reconnection handling
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:
- Channel process dies →
TunnelRegistryreceives a:DOWNmessage viaProcess.monitor. - Registry cleans up the dead connection.
- Pending HTTP requests receive a 502 error (tunnel disconnected).
- CLI reconnects → new Channel process → new registry entry.
- 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:
- Connect — Open a WebSocket to
wss://server/socket/websocket?token=<auth_token> - Join — Send a
phx_joinevent to thetunnel:connecttopic - Receive confirmation — Server replies with the assigned subdomain and tunnel ID
- 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:
request_id— The unique identifier we must include in our responsemethod,path,headers,body— The HTTP request detailsbody_encoding— Either"raw"(UTF-8 text) or"base64"(binary content)
The forwarding flow:
- Parse the incoming
tunnel_request - Build an HTTP request using
reqwest - Forward it to
localhost:PORT - Capture the response (status, headers, body)
- Send a
tunnel_responseback with the samerequest_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.