# Egress

**Page:** kit/egress

[Download Raw Markdown](./kit/egress.md)

---

# Egress

`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:

```text
https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com
```

```bash
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:

```text
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](/foundation/proxy/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.


A container's URLs are reachable by anyone who has them. An open egress endpoint is an open proxy: anyone with the hostname can send traffic through it, billed to your server and attributed to your exit IP. Set [proxy permissions](/foundation/proxy/permissions/) on the `egress` service before you share the URL or configure an upstream.


## Capabilities


  
  `CONNECT` opens a TCP tunnel. The proxy never sees inside the TLS session.
  
  
  Absolute-URI requests (`GET http://example.com/path`) are forwarded to the origin.
  
  
  Chain to a SOCKS5 or HTTP proxy so traffic exits somewhere else.
  
  
  Change or clear the upstream over HTTP. No restart.
  


## API Endpoints Summary

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/v1/egress/health` | Service health and runtime info |
| `GET` | `/api/v1/egress/upstream` | Current upstream status. Credentials are never returned |
| `PUT` | `/api/v1/egress/upstream` | Set the upstream from a `text/plain` body |
| `POST` | `/api/v1/egress/upstream` | Same as `PUT` |
| `DELETE` | `/api/v1/egress/upstream` | Disable 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`.

## Client configuration

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.



```bash
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/
```


```bash
export https_proxy=https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com:443
export http_proxy="$https_proxy"

# Tools that honour the standard variables now route through the container
git clone https://github.com/you/repo.git
pip install requests
```


```javascript
// No dependency and no agent to construct: Node's own fetch reads the standard
// proxy variables once proxy support is enabled. Nothing in the code changes.
const res = await fetch('https://ip.hoody.com');
const { data } = await res.json();
console.log(data.ip, data.ip_info.country);
```

Enable it with the flag or the matching environment variable:

```bash
export https_proxy=https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com:443

node --use-env-proxy app.js
# or
NODE_USE_ENV_PROXY=1 node app.js
```


```javascript
// Settings > System > Open your computer's proxy settings, then set the
// automatic proxy configuration (PAC) URL to a file containing this.
// The manual host and port fields will not work: they connect to the proxy
// in plaintext, and the edge answers 400 on that port.
function FindProxyForURL(url, host) {
  return "HTTPS PROJECT-CONTAINER-egress.SERVER.containers.hoody.com:443";
}
```

To scope it to one browser session instead of the whole machine, pass it on the command line with a throwaway profile:

```bash
google-chrome \
  --proxy-server="https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com:443" \
  --user-data-dir=/tmp/chrome-egress
```


```javascript
// Save this as proxy.pac and select it under Settings > Network Settings >
// Automatic proxy configuration URL. The manual host and port fields will not
// work: they connect to the proxy in plaintext, which this endpoint refuses.
function FindProxyForURL(url, host) {
  return "HTTPS PROJECT-CONTAINER-egress.SERVER.containers.hoody.com:443";
}
```



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

```bash
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.

## Upstream proxies

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

| Scheme | Behaviour |
|---|---|
| `socks5h` | SOCKS5. The destination hostname is sent to the upstream, which resolves it |
| `socks5` | SOCKS5. The hostname is resolved locally and an address is sent |
| `http` | Chains through an HTTP proxy using `CONNECT` |
| `https` | Same 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:

```bash
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"
```

```json
{
  "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:

```bash
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.

### Where the setting lives

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.

## Exit from your own machine

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.

```bash
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](#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](/kit/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.

### How the chain fits together

```
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.

### What it will and will not dial

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.


`--allow-private` does not only open your LAN. It turns off every special-address check at once, so the proxy will also dial loopback services on the machine running it, link-local addresses including the cloud metadata endpoint `169.254.169.254`, CGNAT space, and the reserved ranges. Anyone holding the container URL can then reach all of them. Use `--allow-ports` and a destination you control rather than reaching for this flag by default.


Widen either one deliberately:

```bash
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
```

### Choosing a resolver

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:

```bash
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.

### Other options

| Flag | Effect |
|---|---|
| `--alias <name>` | Create an [alias](/foundation/proxy/aliases/) so the URL you hand out carries no container ID. Deleted again when the command stops |
| `--auto-alias` | The 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-upstream` | Take over a container that already has an upstream configured |
| `--no-verify` | Skip the `ip.hoody.com` exit check at startup. Needed if `--allow-ports` excludes 443, since the check itself connects on 443 |
| `--quiet-events` | Do 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.

### When it stops

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:

```bash
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.

### When a request is refused

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

| Reason | Meaning |
|---|---|
| `private-destination-blocked` | The name or address resolved outside public space. `--allow-private` permits it |
| `port-not-allowed` | The port is not in `--allow-ports` |
| `ipv6-unsupported` | An IPv6 destination, or an address type the proxy does not implement. There is no flag for this |
| `dns-failed` | The resolver did not answer, or returned no IPv4 address |
| `refused` | The destination rejected the connection |
| `unreachable` | The destination could not be reached, or the relay itself failed: a tunnel send error or a client reset also report this |
| `connect-timeout` | The destination did not answer in time, or the SOCKS handshake stalled before a destination was named |
| `idle-timeout` | Neither direction sent anything for the idle period |
| `concurrency-limit` | `--max-concurrent` is saturated. Connections above the cap are refused, not queued |
| `auth-failed` | Something inside the container tried the loopback port with the wrong credentials |
| `policy-invalid` | A `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-large` | The peer is not speaking SOCKS5 correctly. Only CONNECT is implemented, so BIND and UDP ASSOCIATE report `command-unsupported` |
| `client-closed` | The peer disconnected before it had named a destination. An early hang-up, not malformed SOCKS5 |
| `backpressure-exceeded` | The 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.

### From the SDK

```ts
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:

```typescript
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;
}
```

## Alias hostnames

The URL above contains the project and container IDs. To hand the endpoint to something you do not control, put an [alias](/foundation/proxy/aliases/) 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.



```bash
hoody proxy create --container-id CONTAINER --program egress --alias acme-exit
```


```typescript
const { data } = await hoody.api.proxyAliases.create({
  container_id: 'CONTAINER',
  program: 'egress',
  alias: 'acme-exit',
});
console.log(data.url);
```



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

```bash
curl -x https://acme-exit.SERVER.containers.hoody.com:443 https://ip.hoody.com
```

## Access control

`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](/foundation/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](/foundation/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.

## Health

```bash
curl https://PROJECT-CONTAINER-egress.SERVER.containers.hoody.com/api/v1/egress/health
```

```json
{
  "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.

## Use Cases

- **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.

## Best Practices

- 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.

## Troubleshooting

**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.

## What's Next


  
  
  
  


---