Skip to content
Hoody.com

hoody-egress is the container’s outbound proxy. Point an HTTP client at it and the request leaves through the container, or through an upstream proxy you configure at runtime.

It answers on its own container URL, so anything that accepts a proxy setting can use it:

https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com
Terminal window
curl -x https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com:443 https://ip.hoody.com

That works on a running Kit container with no setup: there is nothing to install, start, or configure first. Without an upstream, requests exit from the container’s own IP. Set an upstream and the same URL routes through it instead, which is how you change the exit IP without touching the client.

An indexed form works too, and resolves to the same service:

https://PROJECT-CONTAINER-egress-1.SERVER.containers.hoody.com
https://PROJECT-CONTAINER-egress-2.SERVER.containers.hoody.com

There is one egress process per container listening on a single port, so every index reaches it and they all share one upstream setting. The index is not a second proxy. What it does change is permissions, which are evaluated per service index: you can put one credential on egress-1 and a different one on egress-2 while both exit through the same address.

HTTPS tunnelling

CONNECT opens a TCP tunnel. The proxy never sees inside the TLS session.

Plain HTTP forwarding

Absolute-URI requests (GET http://example.com/path) are forwarded to the origin.

Upstream chaining

Chain to a SOCKS5 or HTTP proxy so traffic exits somewhere else.

Runtime reconfiguration

Change or clear the upstream over HTTP. No restart.

MethodEndpointDescription
GET/api/v1/egress/healthService health and runtime info
GET/api/v1/egress/upstreamCurrent upstream status. Credentials are never returned
PUT/api/v1/egress/upstreamSet the upstream from a text/plain body
POST/api/v1/egress/upstreamSame as PUT
DELETE/api/v1/egress/upstreamDisable the upstream

The health route matches on path alone, so it answers any method other than OPTIONS; GET is simply the conventional one. These are the only paths the service serves. Any other request whose target begins with / returns 404 and is never forwarded, so a mistyped management call cannot turn into an outbound request. The one exception is OPTIONS, which is answered 204 with CORS headers before route matching, so browser preflight works against the management API. Proxy clients are unaffected either way, because they send absolute-URI targets or CONNECT.

The endpoint speaks the standard HTTP proxy protocol, so anything that already knows how to use a proxy can use this one: curl, git, pip, npm, Node.js, Python, Chrome, and Firefox all work without a plugin or helper process.

One detail decides how you configure each of them. The connection to the proxy is itself TLS, so the proxy address carries an https:// scheme. Command-line tools take that scheme directly.

Browsers reach it from their settings, but through the automatic-configuration field rather than the manual one. A browser’s manual “HTTP proxy” host and port fields open a plaintext connection to the proxy, which the edge refuses with a 400 (“The plain HTTP request was sent to HTTPS port”). Point them at a PAC file returning HTTPS host:443 instead, which both Chrome and Firefox accept from their normal proxy settings.

Terminal window
EGRESS=https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com:443
# HTTPS through a CONNECT tunnel. ip.hoody.com reports the address it saw,
# so it shows the exit IP the destination actually receives.
curl -x "$EGRESS" https://ip.hoody.com | jq -r '.data.ip'
# Plain HTTP
curl -x "$EGRESS" http://example.com/

Whatever the client, confirm the route with a request that reports the address it came from:

Terminal window
curl -x "$EGRESS" https://ip.hoody.com | jq -r '.data.ip, .data.ip_info.country'

ip.hoody.com reports the address and location it saw the request come from: the container’s, or the upstream’s once one is set.

An upstream changes where traffic exits. The service accepts four schemes:

SchemeBehaviour
socks5hSOCKS5. The destination hostname is sent to the upstream, which resolves it
socks5SOCKS5. The hostname is resolved locally and an address is sent
httpChains through an HTTP proxy using CONNECT
httpsSame as http, with TLS to the upstream

Prefer socks5h when the upstream should also handle DNS. With socks5, the container resolves the name, so DNS still originates locally even though the traffic does not.

Set the upstream with a text/plain body. The first line that is neither blank nor a # comment is read as the URL:

Terminal window
EGRESS=https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com
curl -X PUT --data-binary 'socks5h://user:pass@203.0.113.10:1080' \
"$EGRESS/api/v1/egress/upstream"
{
"enabled": true,
"scheme": "socks5h",
"host": "203.0.113.10",
"port": 1080,
"auth": true,
"config_path": "/hoody/storage/hoody-egress/config/upstream_proxy.txt"
}

Credentials go in the URL. The response reports whether credentials are set through the auth boolean and never returns them.

Read the current state or clear it:

Terminal window
curl "$EGRESS/api/v1/egress/upstream"
curl -X DELETE "$EGRESS/api/v1/egress/upstream"

An empty body has the same effect as DELETE.

Request bodies are limited to 4096 bytes. A request without Content-Length returns 411, an oversized body returns 413, and an unparseable URL returns 400.

The upstream is written to /hoody/storage/hoody-egress/config/upstream_proxy.txt at mode 0600. That file is the state the service reads at startup, which is what makes a setting survive a restart, and the API is one of three ways to change it:

  • The API, as above. Takes effect immediately.
  • Editing the file inside the container. The process re-reads it about once a second, so a change applies without an API call.
  • The --upstream flag at startup, which accepts either a URL or a path to a file containing one. The daemon program definition uses --upstream-config to point at the path above.

Deleting the file disables the upstream within a second, the same as a DELETE. A file that never existed does not override an upstream given on the command line.

Because the file holds credentials, it is written atomically and kept at 0600. Treat it as a secret when you snapshot or copy a container.

The upstream does not have to be a third-party proxy. It can be your own computer, which turns the container URL into a proxy whose traffic leaves your home or office connection:

You need to be signed in (hoody login), and the container must be running with its tunnel and egress services up. The startup check also needs to reach ip.hoody.com, both directly and through the container.

Terminal window
hoody --container CONTAINER egress local
Binding tunnel and wiring egress…
Local exit active
Proxy URL: https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com
Exit IP: 203.0.113.42 (SG) confirmed
Loopback: 127.0.0.1:46077 (inside the container)
Destinations: public only, ports 80,443
Resolver: system
Anyone with this URL relays through your connection. Treat it like a password.
Use it: curl -x https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com https://ip.hoody.com
Press Ctrl+C to stop and restore the container.

The command holds the chain open until you stop it with Ctrl+C, which clears the upstream and restores the container. See When it stops for what happens when that does not succeed. Point any client at the proxy URL while it runs and requests exit from the machine running the command.

Nothing listens on your machine, and the command makes no inbound connections. Everything it opens is outbound: a WebSocket to the container’s tunnel service, a TCP connection per proxied request, the DNS lookups those requests need, the startup exit-IP check, and the ordinary API calls that configure the container. There is no port to forward and no firewall rule to add.

client ──HTTPS proxy──▶ container egress
│ upstream = socks5h://127.0.0.1:<port>
tunnel PULL bind on container loopback
│ WebSocket
your machine ──▶ destination

The loopback port is a tunnel bind, so the SOCKS5 conversation is terminated on your machine and the destination is dialled from there. socks5h keeps name resolution on your side too, so the container never performs the DNS lookups. It still sees the hostname itself: a client’s CONNECT example.com:443 reaches the container’s egress before anything is forwarded.

The SOCKS5 credentials are generated per run and required: that loopback port is reachable by every process inside the container, so it is never anonymous.

Because the exit runs on your machine, an unguarded destination is not somewhere on the internet. It is anything that machine can reach, including your router’s admin page and services on loopback. Two limits apply by default:

  • Only public IPv4 addresses. Every resolved IPv4 address is checked, and a name that resolves to private, loopback, link-local, CGNAT, or reserved space is refused. The check runs on the resolved address and the connection is made to that same address, so a name cannot resolve to something public and then be dialled somewhere else. IPv6 is not supported: a request for an IPv6 destination is refused, and IPv6 records are ignored when a name resolves to both.
  • Only ports 80 and 443.

Widen either one deliberately:

Terminal window
hoody --container CONTAINER egress local --allow-ports 80,443,8080
hoody --container CONTAINER egress local --allow-ports '*'
hoody --container CONTAINER egress local --allow-private # see the warning above

Destination names are resolved on your machine, which by default means your own resolver sees every hostname a client asks for. Name a different one:

Terminal window
hoody --container CONTAINER egress local --dns 1.1.1.1,9.9.9.9

Entries accept an optional port (1.1.1.1:5353). Naming a resolver means lookups go there and nowhere else: if it does not answer, the request fails rather than falling back to the system resolver, and /etc/hosts is not consulted.

FlagEffect
--alias <name>Create an alias so the URL you hand out carries no container ID. Deleted again when the command stops
--auto-aliasThe same, with a generated name
--dns <servers>Resolvers for destination lookups (see above)
--port <port>Pin the container loopback port instead of letting the kernel assign one
--max-concurrent <n>Concurrent proxied connections (default 128)
--replace-upstreamTake over a container that already has an upstream configured
--no-verifySkip the ip.hoody.com exit check at startup. Needed if --allow-ports excludes 443, since the check itself connects on 443
--quiet-eventsDo not print a line per proxied connection

The startup check is worth keeping: it fetches ip.hoody.com twice, once directly and once through the proxy, and reports confirmed only when both report the same address. A MISMATCH is a warning, not a failure: the exit stays up and you decide whether the difference is explained. A check that cannot complete at all does stop startup, and the container is unwired again. That unwind is best-effort in the same way: if it cannot clear the upstream it says so in the error rather than reporting a clean failure. Treat a mismatch as a signal to investigate rather than proof of a broken chain: it also fires on a dual-stack machine, where the direct request can leave over IPv6 while the proxy dials IPv4 only.

Ctrl+C clears the upstream and restores the container. If the tunnel drops on its own, the command clears the upstream and exits non-zero rather than sitting there looking active while nothing routes. Either way the clear is tried up to three times and then read back to confirm it, so a stop is only reported as clean when a second call agrees the upstream is gone; otherwise the command says so and prints the recovery command below.

It also tries not to clear an upstream that is no longer its own. The upstream is a single container-wide setting, so a second exit started against the same container replaces the first. When the first one stops it reads the upstream back and leaves it alone if the port is not the one it bound. The check is best-effort in one direction: if that read fails it goes ahead and clears, on the grounds that a container left pointing at a dead port is the worse of the two outcomes. It matches on port alone, so it is a guard against the ordinary takeover, not an ownership proof: a replacement that reuses the same port number reads as its own, and a read that succeeds just before the other exit finishes installing can still be followed by a clear that removes it.

When it does leave the upstream alone, the command says so and exits zero, because that is the correct outcome rather than a failed teardown. Its own tunnel and alias are still removed. From the SDK the same case is upstreamHandedOver: true on the teardown report, with upstreamCleared and upstreamVerified both false — the upstream really is still enabled, it just is not yours.

A kill -9, a power cut, or a closed laptop gets no such chance. The container is then left pointing at a loopback port that no longer answers, so every request through its egress fails until you clear it:

Terminal window
hoody --container CONTAINER egress upstream clear

For the same reason the command refuses to start on a container that already has an upstream set. That is usually the leftover above, but it could be a proxy you configured deliberately, and stopping the exit would clear it. Since the kit never returns upstream credentials, one it replaced could not be put back. Clear it yourself, or pass --replace-upstream to take it over knowingly.

Unless you pass --quiet-events, the command prints a line per connection, and a refused one carries the reason:

ReasonMeaning
private-destination-blockedThe name or address resolved outside public space. --allow-private permits it
port-not-allowedThe port is not in --allow-ports
ipv6-unsupportedAn IPv6 destination, or an address type the proxy does not implement. There is no flag for this
dns-failedThe resolver did not answer, or returned no IPv4 address
refusedThe destination rejected the connection
unreachableThe destination could not be reached, or the relay itself failed: a tunnel send error or a client reset also report this
connect-timeoutThe destination did not answer in time, or the SOCKS handshake stalled before a destination was named
idle-timeoutNeither direction sent anything for the idle period
concurrency-limit--max-concurrent is saturated. Connections above the cap are refused, not queued
auth-failedSomething inside the container tried the loopback port with the wrong credentials
policy-invalidA denyCidrs rule could not be parsed. The destination is refused rather than the rule being ignored, so a typo cannot quietly disable a deny you asked for. SDK only; the CLI has no flag for denyCidrs
not-socks5, no-acceptable-auth, command-unsupported, handshake-too-largeThe peer is not speaking SOCKS5 correctly. Only CONNECT is implemented, so BIND and UDP ASSOCIATE report command-unsupported
client-closedThe peer disconnected before it had named a destination. An early hang-up, not malformed SOCKS5
backpressure-exceededThe peer kept sending after the destination stopped accepting data. The stream is dropped rather than buffered without limit

Pressing Ctrl+C a second time while it is stopping abandons the teardown and exits immediately. That is deliberate, so a stuck teardown cannot trap the process, but it can leave the upstream set: the command says so and prints the clear command.

import { startLocalExit } from 'hoody-sdk';
const { data } = await client.api.containers.get(containerId);
const handle = await startLocalExit({
client,
container: data,
policy: { allowPorts: [80, 443], dnsServers: ['1.1.1.1'] },
alias: true,
onConnect: (e) => console.log(e.ok ? 'ok' : 'denied', e.host, e.port, e.reason),
});
console.log(handle.proxyUrl, handle.verification?.exitIp);
await handle.stop(); // clears the upstream; closes the tunnel once that is confirmed

stop() returns a report saying whether the upstream was cleared and verified, so a caller can act on a teardown that only partly succeeded rather than assuming it worked. If the upstream cannot be confirmed cleared it deliberately leaves the tunnel open, because a container pointing at a dead port fails every request; call forceStop() to close it anyway. A stop() that failed that way can be called again once you have fixed whatever blocked it.

Startup applies the same rule. If it fails after the upstream is set and cannot clear it again, the tunnel is left open for the same reason, and the rejection is a LocalExitStartupError carrying closeTunnel() so a long-running caller can still release it:

import { startLocalExit, LocalExitStartupError } from 'hoody-sdk';
try {
handle = await startLocalExit({ client, container: data });
} catch (err) {
if (err instanceof LocalExitStartupError) {
// The container still points at err.containerPort. Clear it, then:
await err.closeTunnel(); // idempotent, never throws
}
throw err;
}

The URL above contains the project and container IDs. To hand the endpoint to something you do not control, put an alias in front of it. An alias made this way is permanent and independent of any exit; the one hoody egress local --alias creates belongs to that command and is deleted when it stops.

Terminal window
hoody proxy create --container-id CONTAINER --program egress --alias acme-exit

The alias serves the same proxy on a hostname of your choosing:

Terminal window
curl -x https://acme-exit.SERVER.containers.hoody.com:443 https://ip.hoody.com

hoody-egress performs no authentication of its own. Like the other Kit services, it accepts whatever reaches its socket, and access is decided at the edge by proxy permissions. Gate the egress service with a password, token, or IP rule before the URL leaves your hands.

Two limits are worth knowing when you plan that gating:

  • The egress service cannot carry proxy hooks. Hooks match on request path, and a forward proxy has no path of its own to match: CONNECT carries a host and port, and an absolute-URI request carries the destination’s path. A hook here would be inert for HTTPS and would filter a third party’s URL space for plain HTTP.
  • Permissions apply per service, not per destination. They decide who may use the proxy, not where those users may go.
Terminal window
curl https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com/api/v1/egress/health
{
"status": "ok",
"service": "hoody-egress",
"built": "2026-08-10T18:20:49Z",
"started": "2026-08-10T18:30:49Z",
"memory": { "rss": 5820416, "heap": null },
"fds": 12,
"pid": 733,
"ip": "203.0.113.7",
"userAgent": "curl/8.1.2"
}

ip is the address the request arrived from, which makes the endpoint a quick way to check what the edge sees as your client IP.

  • Give container traffic a different exit IP. Point the upstream at a proxy in another region and every request through the endpoint leaves from there, without reconfiguring the tools making the requests.
  • One proxy setting for a whole toolchain. Export https_proxy once in the container and git, pip, npm, and anything else honouring the standard variables follow the same route.
  • Reach an API that allowlists a fixed address. Chain to a proxy whose IP is on the allowlist and calls from any container arrive from that address.
  • Inspect what the edge sees. The ip field on the health endpoint reports the client address as it arrives, which is useful when debugging IP-based permission rules.
  • Set permissions on the egress service before configuring an upstream. An unrestricted endpoint with an upstream attached is an open relay pointed at somebody else’s network.
  • Use socks5h rather than socks5 when the upstream should resolve DNS, so lookups do not reveal the destinations from the container’s own resolver.
  • Rotate upstream credentials by issuing a new PUT. Note they are not kept out of the filesystem by living in the URL: the kit writes the URL verbatim into upstream_proxy.txt, so any process that can read that file as root can read the credentials. Scope them to the upstream they authenticate.
  • Front the endpoint with an alias when a third party will use it, so you are not handing out the container ID.
  • Check GET /api/v1/egress/upstream after a change. It reports the parsed scheme, host, and port, which catches a malformed URL that was accepted as text but is not what you meant.

Requests exit from the container’s IP instead of the upstream. Check GET /api/v1/egress/upstream. If it returns {"enabled": false}, no upstream is set, and the service dials destinations directly.

A PUT returns 411. The request had no Content-Length. Send the body with --data-binary rather than as a streamed body.

A PUT returns 400. The first non-comment line did not parse as a URL with one of the four supported schemes. A missing :// is the usual cause.

A management path returns 404. Only the five endpoints in the summary table are served. Everything else beginning with / is refused rather than forwarded.

DNS resolves in the wrong place. socks5 resolves in the container; socks5h resolves at the upstream. Switch schemes to move the lookup.