Skip to content
Hoody.com

hoody-tunnel carries traffic between your laptop and your container over one multiplexed WebSocket, in both directions. An EXPOSE binding publishes a local HTTP or WebSocket server at your container’s public URL. A PULL binding makes a TCP service on your laptop reachable on the container’s loopback.

Your laptop opens the WebSocket, so it never accepts an inbound connection. The container’s public hostname and its certificate already exist, so the tunnel adds no DNS, certificate, or signup step.

Publishing localhost:3000 with a standalone tunnel client means installing it, creating an account, configuring a tunnel, working around its rate limits, and paying for a custom domain. The other direction, letting code on a remote machine reach a database running on your laptop, is normally a second tool with its own setup.

hoody-tunnel does both from one session. The kit runs in every container, and the public URL it serves is the container’s own domain, so no third-party account sits in the path.

import { tunnelExpose, tunnelPull } from 'hoody-sdk';
// Visitors reach your laptop's port 3000 at the container's public URL
const app = await tunnelExpose({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token, containerPort: 3000, to: { host: '127.0.0.1', port: 3000 } });
// Your laptop's Postgres is now reachable at 127.0.0.1:5432 inside the container
const db = await tunnelPull({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token, containerPort: 5432, to: { host: '127.0.0.1', port: 5432 } });

Both bindings ride the same session. Hoody Proxy terminates TLS, runs ACME, and serves custom domains upstream, so hoody-tunnel never handles a certificate. Your laptop never opens a listening port to the outside world; it is the WebSocket client in both directions.


Traffic arrives at the public URL, passes through Hoody Proxy into the container, and crosses the WebSocket to your laptop:

[Your laptop :3000] → WebSocket → [Container :3000] → Hoody Proxy → [Public internet]

Visitors request https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com and reach your laptop’s port 3000. HTTP/1.1 requests and WebSocket upgrades both pass through. Because your laptop is the client rather than the server, this works from behind NAT, a corporate firewall, or a captive portal.

Container-side code connects to a loopback listener, and the tunnel carries that connection over the WebSocket to a TCP service on your laptop:

[Your laptop :5432] ← WebSocket ← [Container 127.0.0.1:5432]

Code running in the container connects to 127.0.0.1:5432 and reaches Postgres on your laptop. The tunnel forwards raw TCP without inspecting or parsing it, so anything that speaks TCP works: databases, Redis, gRPC, SSH.


import { tunnelExpose } from 'hoody-sdk';
await using app = await tunnelExpose({
url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect',
token: process.env.HOODY_TOKEN!,
containerPort: 3000,
to: { host: '127.0.0.1', port: 3000 },
});
console.log(app.publicUrl); // https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com
// Visitors now reach your laptop's port 3000 via this URL
import { tunnelPull } from 'hoody-sdk';
await using db = await tunnelPull({
url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect',
token: process.env.HOODY_TOKEN!,
containerPort: 5432,
to: { host: '127.0.0.1', port: 5432 }, // your local Postgres
});
// Container-side code can now: psql -h 127.0.0.1 -p 5432

Instead of starting a local server, pass a fetch handler and the SDK hosts it for you:

import { tunnelServe } from 'hoody-sdk';
await using server = await tunnelServe({
container: CONTAINER_ID,
token: process.env.HOODY_TOKEN!,
containerPort: 3000,
fetch(req) {
return new Response('Hello from my laptop!');
},
});
console.log(server.publicUrl); // public URL serving your handler

tunnelServe() boots a Bun.serve on a random local port and exposes that port, so your handler runs on your laptop and the kit never executes user code. The handler itself is a standard Request to Response function.


A single session can hold many bindings simultaneously. The high-level helpers cover the common cases: expose two HTTP services and pull a database, each returning its own handle. For direct control over a session and its bindings, drop down to the low-level box.tunnel.* API:

import { tunnelExpose, tunnelPull } from 'hoody-sdk';
// Expose two HTTP services
const web = await tunnelExpose({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token, containerPort: 3000, to: { host: '127.0.0.1', port: 3000 } });
const api = await tunnelExpose({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token, containerPort: 5000, to: { host: '127.0.0.1', port: 5000 } });
// Pull a database
const pg = await tunnelPull({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token, containerPort: 5432, to: { host: '127.0.0.1', port: 5432 } });
console.log(web.publicUrl); // https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com
console.log(api.publicUrl); // https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com
// Container code reaches your Postgres at 127.0.0.1:5432
// ... run until done ...
await web.close();
await api.close();
await pg.close();

Pass containerPort: 0 (or omit it on the low-level bind) and the kit picks a free port for you, in both directions:

  • PULL: the kit grabs a kernel-ephemeral loopback port.
  • EXPOSE: the kit picks a random available port in the 20000-65534 range.
await using db = await tunnelPull({
container, token,
containerPort: 0, // kit assigns a kernel-ephemeral loopback port
to: { host: '127.0.0.1', port: 5432 },
});
console.log(db.bind.containerPort); // e.g. 43217
// Container code reaches your Postgres at 127.0.0.1:43217

Either way the chosen port comes back in the BIND_OK response (bind.containerPort), and for EXPOSE the resulting publicUrl is the URL at which the binding is publicly reachable.


For high-throughput workloads, the hoody-tunnel.v2 subprotocol spreads streams across multiple parallel WebSockets. Negotiate it through the low-level box.tunnel.* API; the number of connections the kit grants comes back as connectionsGranted (visible in both the /tunnels and /sessions responses, alongside the v2 indicator: protocol: "hoody-tunnel.v2" in /tunnels, isV2: true in /sessions).

Frames are pinned to the WebSocket that delivered each STREAM_OPEN, preserving per-stream ordering while letting unrelated streams parallelize across connections. v1 sessions keep working unchanged.


If your WebSocket drops (laptop sleeps, network blip, train tunnel) listeners stay up for 60 seconds by default. EXPOSE listeners return 503 Service Unavailable with Retry-After: 5 to visitors during the gap. Reconnect within the grace period and everything resumes exactly where it left off.

Reconnect with the same session ID and the kit atomically rehydrates all bindings under the new WebSocket, so bindings are restored with no visitor downtime. Session resume lives on the low-level box.tunnel.* API: capture the session ID from the live session, then reconnect with it after a disconnect. (Admin killSession is the exception: it bypasses orphan parking, so a killed session cannot be resumed.)

A new session can steal an EXPOSE binding from another session (or from an orphaned grace-period session) by setting takeover: true:

const binding = await tunnelExpose({
container, token,
containerPort: 3000,
to: { host: '127.0.0.1', port: 3000 },
takeover: true, // atomically claim port 3000 from any prior holder
});

The old session’s streams on that binding receive RESET(BIND_TAKEOVER) and the new owner starts serving immediately.


Six HTTP endpoints let you observe and manage the tunnel from anywhere: the container, another container, your laptop, an AI agent with a fetch call, or the Hoody dashboard. The endpoints and auth model are the same in every case, because each is an ordinary HTTP request.

Terminal window
# Liveness + version
hoody tunnel health -c <container-id>
# Prometheus-format metrics
hoody tunnel metrics -c <container-id>
# Unified view: sessions + bindings (EXPOSE + PULL) + stream counts + FD budget
hoody tunnel list -c <container-id>
# Kill a session
hoody tunnel sessions kill sess_abc123 -c <container-id> --yes

A GET /tunnels response looks like this:

{
"sessions": [{
"sessionId": "a1b2c3",
"peerAddr": "203.0.113.42:51234",
"protocol": "hoody-tunnel.v1",
"connectionsGranted": 1,
"activeStreams": 3,
"exposeBindings": [{ "bindId": 1, "containerPort": 3000 }],
"pullBindings": [{ "bindId": 2, "containerPort": 5432 }]
}],
"orphanedSessions": 0,
"totalStreams": 3,
"totalBindings": 2,
"fdPermitsAvailable": 4094
}

For the full request/response schema of every endpoint, jump to the API reference:


[Visitor browser / curl / agent]
│ https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com
┌───────────────────────────┐
│ Hoody Proxy (nginx) │ TLS termination, SNI routing
└─────────────┬─────────────┘
│ HTTP/1.1 or WS upgrade
┌────────────────────────────────────────────────────┐
│ Container │
│ ┌─────────────────────────────────────────┐ │
│ │ hoody-tunnel (Rust / axum) │ │
│ │ │ │
│ │ Base listener :50 (control plane) │ │
│ │ └── /api/v1/tunnel/connect (WS) │ │
│ │ │ │
│ │ EXPOSE listeners (dynamic) │ │
│ │ :3000 → tunneled to laptop │ │
│ │ :5000 → tunneled to laptop │ │
│ │ │ │
│ │ PULL listeners (dynamic, loopback) │ │
│ │ 127.0.0.1:5432 → tunneled to laptop │ │
│ └────────────────┬────────────────────────┘ │
└────────────────────┼───────────────────────────────┘
│ multiplexed WebSocket
┌────────────────────────────┐
│ Tunnel SDK (your laptop) │
│ Bun 1.3+ / Node 22+ │
└────────────────┬───────────┘
[Local services: :3000, :5000, :5432]

The tunnel is a stateless multiplexer. Flow control runs per-stream and per-session so a slow visitor never blocks the others. Certificates live upstream in Hoody Proxy; hoody-tunnel forwards bytes.


LimitValue
Max sessions8 (default; --max-sessions)
Max bindings per session8 (default; --max-bindings-per-session)
Max concurrent streams per session1024 (default; --max-streams-per-session)
Max frame payload65,536 bytes
Per-stream flow control window1 MiB (default; --stream-initial-window)
Per-session flow control window16 MiB (default; --session-initial-window)
Hello timeout5 seconds (default; --hello-timeout)
Ping interval30 seconds of receive inactivity
Pong timeout60 seconds (default; --pong-timeout)
Takeover grace period60 seconds (default; --takeover-grace)
Idle timeout300 seconds (default; --idle-timeout)
killSession drain budget0-5000 ms (default 50)

  • Local development: share your dev server with teammates or test webhooks without deploying anywhere
  • Database access: pull your container’s production Postgres to a local GUI (DBeaver, pgAdmin, TablePlus)
  • AI agent tooling: let an AI agent running in your container call services running on your laptop
  • Demo and review: show a client your work in progress without pushing to staging
  • Hybrid workflows: run heavy GPU workloads locally and expose them through your container’s public domain
  • Webhook development: receive GitHub, Stripe, or GitLab webhooks on your laptop during development