Tunnel
Section titled “Tunnel”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.
Compared with ngrok
Section titled “Compared with ngrok”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 { HoodyClient } from 'hoody-sdk';
const hoody = new HoodyClient({ baseURL: 'https://api.hoody.com' });const box = await hoody.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Visitors reach your laptop's port 3000 at the container's port-3000 URLconst app = await box.tunnel.expose({ 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 containerconst db = await box.tunnel.pull({ containerPort: 5432, to: { host: '127.0.0.1', port: 5432 } });The container-scoped client builds the tunnel’s WebSocket URL from the container’s project ID, container ID, and server, so you never write it by hand. Each call opens its own session; the low-level TunnelSession puts several bindings on one session. HTTPS and custom domains work on the container’s public URL as usual, with no certificate setup in the tunnel. Your laptop never opens a listening port to the outside world; it is the WebSocket client in both directions.
EXPOSE and PULL
Section titled “EXPOSE and PULL”EXPOSE
Section titled “EXPOSE”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-http-3000.SERVER.containers.hoody.com, the container’s URL for port 3000, and reach your laptop’s port 3000. The tunnel-1 hostname is the kit’s own connect and inspection endpoint, not your application. 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.
Quick start
Section titled “Quick start”The examples use a container-scoped client: withContainer() takes the container’s ID, project ID, and server, and needs no API call when you pass all three. Its tunnel.expose(), tunnel.pull(), and tunnel.serve() methods resolve the tunnel URL themselves. Replace CONTAINER_ID, PROJECT_ID, and SERVER with your container’s values (the id, project_id, and server fields of api.containers.get). Examples after this section reuse box.
A new container’s URLs are open by default. If you gate the tunnel service with a proxy permission rule, pass the matching credential as kitAuth, either to withContainer() or per call; the account token is never sent to the tunnel. Without a container-scoped client, the package-root tunnelExpose(), tunnelPull(), and tunnelServe() take the WebSocket URL as url.
Expose a local HTTP server
Section titled “Expose a local HTTP server”import { HoodyClient } from 'hoody-sdk';
const hoody = new HoodyClient({ baseURL: 'https://api.hoody.com' });const box = await hoody.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
await using app = await box.tunnel.expose({ containerPort: 3000, to: { host: '127.0.0.1', port: 3000 },});
// Visitors now reach your laptop's port 3000 at// https://PROJECT-CONTAINER-http-3000.SERVER.containers.hoody.comconsole.log(app.bind.containerPort); // 3000# The tunnel data plane is a WebSocket with a binary framing protocol, so# use the SDK above. The REST endpoints are for inspection (see below).
# List active bindings to confirm the expose landedcurl "https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/bindings"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Lists active bindings on the container’s tunnel service, so you can confirm the expose landed.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/bindings&method=GET&response=transparent Pull a local service into the container
Section titled “Pull a local service into the container”import { HoodyClient } from 'hoody-sdk';
const hoody = new HoodyClient({ baseURL: 'https://api.hoody.com' });const box = await hoody.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
await using db = await box.tunnel.pull({ 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# Inspect active pull bindings from the containercurl "https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/bindings"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Lists active pull bindings from inside the container, so you can confirm the pull landed.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/bindings&method=GET&response=transparent Serve an inline HTTP handler
Section titled “Serve an inline HTTP handler”Instead of starting a local server, pass a fetch handler and the SDK hosts it for you:
await using server = await box.tunnel.serve({ containerPort: 3000, fetch(req) { return new Response('Hello from my laptop!'); },});
// Visitors reach the handler at https://PROJECT-CONTAINER-http-3000.SERVER.containers.hoody.comserve() 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.
Multiple Bindings in One Session
Section titled “Multiple Bindings in One Session”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 and each on its own session. To put several bindings on one session, and for direct control over a multiplexed session and its bindings, import the low-level TunnelSession primitive from the SDK root, which speaks the tunnel WebSocket protocol at /api/v1/tunnel/connect. The other containerClient.tunnel.* methods are the REST inspection and termination surface.
// Expose two HTTP servicesconst web = await box.tunnel.expose({ containerPort: 3000, to: { host: '127.0.0.1', port: 3000 } });const api = await box.tunnel.expose({ containerPort: 5000, to: { host: '127.0.0.1', port: 5000 } });
// Pull a databaseconst pg = await box.tunnel.pull({ containerPort: 5432, to: { host: '127.0.0.1', port: 5432 } });
// web: https://PROJECT-CONTAINER-http-3000.SERVER.containers.hoody.com// api: https://PROJECT-CONTAINER-http-5000.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();Random port assignment
Section titled “Random port assignment”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 box.tunnel.pull({ 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:43217Either way the chosen port comes back in the BIND_OK response (bind.containerPort). For EXPOSE, the binding is reachable at the container’s URL for that port, https://PROJECT-CONTAINER-http-<port>.SERVER.containers.hoody.com. The handle’s publicUrl is set only when the kit runs with a public URL pattern (HOODY_TUNNEL_PUBLIC_URL_PATTERN); otherwise it is undefined.
Privileged ports
Section titled “Privileged ports”0 is the automatic-allocation sentinel above and is always accepted. Any other port below 1024 is restricted:
- 1-79 are rejected outright with
PRIVILEGED_PORT. There is no flag that opens them. - 80-1023 depend on two flags the tunnel is started with:
--allow-privileged-expose(for EXPOSE) and--allow-privileged-pull(for PULL). Hoody containers start the tunnel with both, so EXPOSE 80 or PULL 443 works there. A tunnel you start yourself without them rejects these ports with the samePRIVILEGED_PORTcode, because both flags default to off.
If a bind below 1024 fails with PRIVILEGED_PORT, pick a port at 1024 or above.
Multi-WebSocket (v2 Protocol)
Section titled “Multi-WebSocket (v2 Protocol)”For high-throughput workloads, the hoody-tunnel.v2 subprotocol spreads streams across multiple parallel WebSockets. Request the hoody-tunnel.v2 subprotocol when opening the WebSocket at /api/v1/tunnel/connect — the SDK’s low-level TunnelSession primitive speaks that protocol directly; the number of connections the kit grants comes back as connectionsGranted (visible in both the /tunnels and /sessions responses, alongside the v2 indicator: protocol: "v2" in /tunnels, isV2: true in /sessions). The longer hoody-tunnel.v2 spelling is the WebSocket subprotocol name only — the /tunnels field reports the bare "v1" or "v2", so match on that.
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.
Session Resilience
Section titled “Session Resilience”Takeover grace
Section titled “Takeover grace”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.
Session resume
Section titled “Session resume”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 is part of the tunnel WebSocket protocol on /api/v1/tunnel/connect rather than a containerClient.tunnel.* REST method: capture the live session ID and supply it when reconnecting with the SDK’s low-level TunnelSession primitive. A session ended with box.tunnel.sessions.close() cannot be resumed.
Bind takeover
Section titled “Bind takeover”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 box.tunnel.expose({ 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.
Inspection & Control
Section titled “Inspection & Control”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.
# Liveness + versionhoody tunnel health -c <container-id>
# Prometheus-format metricshoody tunnel metrics -c <container-id>
# Unified view: sessions + bindings (EXPOSE + PULL) + stream counts + FD budgethoody tunnel list -c <container-id>
# Kill a sessionhoody tunnel sessions close sess_abc123 -c <container-id> --yesimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER,});
// Liveness + versionconst health = await containerClient.tunnel.kit.getHealth();
// Prometheus-format metricsconst metrics = await containerClient.tunnel.kit.getMetrics();
// Unified view: sessions + bindings + stream counts + FD budgetconst tunnels = await containerClient.tunnel.list();
// Just the sessionsconst sessions = await containerClient.tunnel.sessions.list();
// Just the bindings (EXPOSE + PULL)const bindings = await containerClient.tunnel.bindings.list();
// Kill a session. session_id is positional; grace_ms is an optional query param inside the options objectawait containerClient.tunnel.sessions.close('sess_abc123', { grace_ms: 100 });BASE="https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel"
# Liveness + versioncurl "$BASE/health"
# Prometheus-format metricscurl "$BASE/metrics"
# Unified view: sessions + bindings + stream counts + FD budgetcurl "$BASE/tunnels"
# Just the sessionscurl "$BASE/sessions"
# Just the bindings (EXPOSE + PULL)curl "$BASE/bindings"
# Kill a session (grace_ms: 0-5000, default 50)curl -X DELETE "$BASE/sessions/sess_abc123?grace_ms=100"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Covers all six inspection endpoints, ending with killing a named session; swap in a real session ID from the sessions or tunnels response first.
# Health
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/health&method=GET&response=transparent
# Metrics
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/metrics&method=GET&response=transparent
# Tunnels (sessions + bindings)
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/tunnels&method=GET&response=transparent
# Sessions
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/sessions&method=GET&response=transparent
# Bindings
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/bindings&method=GET&response=transparent
# Kill a session
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/sessions/sess_abc123?grace_ms=100&method=DELETE&response=transparent 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:
Architecture
Section titled “Architecture” [Visitor browser / curl / agent] │ https://PROJECT-CONTAINER-http-3000.SERVER.containers.hoody.com ▼ ┌───────────────────────────┐ │ Hoody Proxy │ 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.3+ │ └────────────────┬───────────┘ ▼ [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. HTTPS is handled on the container’s public URL; hoody-tunnel forwards bytes.
The examples above import hoody-sdk, whose declared engine range is >=20.3: Node 20.3 or later, with 22 or later recommended because Node 20 is end-of-life. On a Node release whose built-in WebSocket is affected by CVE-2026-12151 (before 22.23.0, all of 23 and 25, 24 before 24.17.0, 26 before 26.3.1) the tunnel socket is opened with the ws package instead. Bun 1.3 or later works as well, and box.tunnel.serve() needs Bun specifically.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Max sessions | 8 (default; --max-sessions) |
| Max bindings per session | 8 (default; --max-bindings-per-session) |
| Max concurrent streams per session | 1024 (default; --max-streams-per-session) |
| Max frame payload | 65,536 bytes |
| Per-stream flow control window | 1 MiB (default; --stream-initial-window) |
| Per-session flow control window | 16 MiB (default; --session-initial-window) |
| Hello timeout | 5 seconds (default; --hello-timeout) |
| Ping interval | 30 seconds of receive inactivity |
| Pong timeout | 60 seconds (default; --pong-timeout) |
| Takeover grace period | 60 seconds (default; --takeover-grace) |
| Idle timeout | 300 seconds (default; --idle-timeout) |
box.tunnel.sessions.close() drain budget | 0-5000 ms (default 50) |
Use Cases
Section titled “Use Cases”- Local development: share your dev server with teammates or test webhooks without deploying anywhere
- Database access: let container code query a Postgres running on your laptop, without publishing that database anywhere
- 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