Skip to content

Your laptop is already a server. You just haven’t plugged it in yet.

hoody-tunnel pushes a local HTTP service out through your container’s public domain — or pulls a container-side TCP service down onto your laptop’s loopback. Both directions. One multiplexed WebSocket. Zero certificate dance, zero DNS edits, zero signup flow.

Exposing localhost:3000 to the internet has always been a side quest. Install ngrok. Sign up for an account. Configure a tunnel. Bump into the rate limit. Pay for a custom domain. Then, when you need the reverse — pulling your container’s Postgres down to a local GUI — that’s a completely different tool.

We rebuilt both directions as one thing.

import { tunnel } from '@hoody-ai/hoody-tunnel-sdk';
// Your laptop is now live on the internet
const app = await tunnel.expose({ url, token, containerPort: 3000, to: { host: '127.0.0.1', port: 3000 } });
// Your container's Postgres is now on 127.0.0.1:5432 inside the container
const db = await tunnel.pull({ url, token, containerPort: 5432, to: { host: '127.0.0.1', port: 5432 } });

One session carries both. TLS, ACME, and custom domains are already handled by Hoody Proxy upstream — the tunnel never touches a certificate. Your laptop never opens a port to the outside world. The WebSocket does all the work.


Push a local HTTP or WebSocket server out through your container to the public internet:

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

Visitors hit https://myapp-3000.hoody.icu and land on your laptop’s port 3000. HTTP/1.1 requests and WebSocket upgrades flow transparently. Your laptop can be behind NAT, a corporate firewall, or a hotel Wi-Fi captive portal — it’s the client, not the server.

Pull a TCP service from your laptop into the container’s loopback:

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

Container-side code connects to 127.0.0.1:5432 and hits your laptop’s Postgres. Raw TCP — the tunnel doesn’t inspect or parse anything. Databases, Redis, gRPC, SSH — whatever speaks TCP works.


import { tunnel } from '@hoody-ai/hoody-tunnel-sdk';
await using app = await tunnel.expose({
url: 'wss://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel/connect',
token: process.env.HOODY_TUNNEL_TOKEN!,
containerPort: 3000,
to: { host: '127.0.0.1', port: 3000 },
});
console.log(app.publicUrl); // https://myapp-3000.hoody.icu
// Visitors now reach your laptop's port 3000 via this URL
import { tunnel } from '@hoody-ai/hoody-tunnel-sdk';
await using db = await tunnel.pull({
url: 'wss://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel/connect',
token: process.env.HOODY_TUNNEL_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

Skip starting a local server entirely — pass a fetch handler and the SDK wraps it:

import { tunnel } from '@hoody-ai/hoody-tunnel-sdk';
await using server = await tunnel.serve({
url: 'wss://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel/connect',
token: process.env.HOODY_TUNNEL_TOKEN!,
containerPort: 3000,
fetch(req) {
return new Response('Hello from my laptop!');
},
});
console.log(server.url); // public URL serving your handler

Internally, tunnel.serve() boots a Bun.serve on a random local port and exposes it — the kit never runs user code. You write a standard Request/Response handler. The internet gets a URL.


A single session can hold many bindings simultaneously. Drop down to the low-level TunnelSession API when you want full control:

import { TunnelSession } from '@hoody-ai/hoody-tunnel-sdk';
const session = new TunnelSession({ url, token });
await session.connect();
// Expose two HTTP services
const web = await session.bind({ kind: 'http', mode: 'expose', containerPort: 3000 });
const api = await session.bind({ kind: 'http', mode: 'expose', containerPort: 5000 });
// Pull a database
const pg = await session.bind({ kind: 'tcp', mode: 'pull', containerPort: 5432 });
console.log(web.publicUrl); // https://myapp-3000.hoody.icu
console.log(api.publicUrl); // https://myapp-5000.hoody.icu
// Container code reaches your Postgres at 127.0.0.1:5432
// ... run until done ...
await session.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 tunnel.pull({
url, 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 reflects that port.


For high-throughput workloads, opt into hoody-tunnel.v2 and the SDK spreads streams across multiple parallel WebSockets:

const session = new TunnelSession({
url, token,
maxConnections: 4, // negotiate v2; open up to 4 parallel WebSockets
});
await session.connect();
console.log(session.hello?.connectionsGranted); // kit may clamp lower

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 when maxConnections is omitted.


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:

const session = new TunnelSession({ url, token });
await session.connect();
// Later, after a disconnect...
const resumed = new TunnelSession({
url, token,
resumeSessionId: session.id,
});
await resumed.connect();
// All bindings restored — no visitor downtime

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 session.bind({
kind: 'http', mode: 'expose',
containerPort: 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. Same endpoints, same auth model — because everything in Hoody is HTTP.

Terminal window
# Liveness + version
hoody tunnel health
# Prometheus-format metrics
hoody tunnel metrics
# Unified view: sessions + bindings + stream counts + FD budget
hoody tunnel list
# Just the sessions
hoody tunnel sessions list
# Just the bindings (EXPOSE + PULL)
hoody tunnel bindings list
# Kill a session (grace_ms: 0-5000, default 50)
hoody tunnel sessions kill sess_abc123 --grace-ms 100

A GET /tunnels response looks like this:

{
"sessions": [{
"sessionId": "a1b2c3",
"peerAddr": "203.0.113.42:51234",
"protocol": "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://myapp-3000.hoody.icu
┌───────────────────────────┐
│ 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 20+ │
└────────────────┬───────────┘
[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 & review — Show a client your work-in-progress without pushing to staging
  • Hybrid workflows — Run heavy GPU workloads locally, expose them through your container’s public domain
  • Webhook development — Receive GitHub, Stripe, or GitLab webhooks on your laptop during development


Your laptop just became a server. Expose local services. Pull remote databases. One WebSocket. Zero configuration. ngrok is a product. Tunneling is a feature.