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 upstream that is set but cannot be used never falls back to the container’s IP: those requests are refused with a 502, covered under Upstream states.

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
POST/api/v1/egress/upstream/renewRenew the lease of an upstream set with ?lease=

These are the only paths the service serves, and only the methods in the table are meaningful. Method enforcement differs between them: /api/v1/egress/upstream rejects anything outside GET, PUT, POST and DELETE with 405, while /api/v1/egress/health dispatches on the path alone and answers the health JSON whatever method you send. Treat GET as the contract there regardless. 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.

These rules govern the destinations the proxy dials itself, which is every request while no upstream is configured. With an upstream in force the destination is settled at the far end of the chain instead, covered under Destinations behind an upstream.

The proxy checks where a request is going before it dials. Private, loopback, link-local, CGNAT, benchmarking, documentation, TEST-NET, multicast and other reserved addresses are refused with 403 and the plain-text body destination not permitted. The answer is the same whether or not anything is listening there. Nothing turns this off: no flag, environment variable, file or API field admits a private destination.

The check runs on the resolved address and the connection is made to that same address, so a name that resolves to something public cannot then be dialled somewhere else.

Destinations are IPv4 only. An IPv6 target is refused in every form it can take:

  • A bracketed literal (CONNECT [2606:4700:4700::1111]:443, GET http://[::1]/) returns 403. The IPv4-mapped (::ffff:8.8.8.8), IPv4-compatible, NAT64 and 6to4 spellings are refused on their family, without the IPv4 inside them being read.
  • A name this proxy resolves itself returns 403 when it has no IPv4 address. AAAA answers are dropped before the IPv4 check runs, so a name that answers with both families still works over IPv4.
  • An unbracketed literal (CONNECT ::1:443) is a malformed authority and returns 400. So is a bracketed authority that does not hold a plain IPv6 address: [fe80::1%eth0], [example.com] and [].

Use the IPv4 form of the destination instead.

Setting an upstream moves the destination decision to the exit. That is the point of a chained exit: the proxy is no longer the thing reaching the destination, so it stops ruling on where the destination is.

Once an upstream is in force, the private and reserved address rules above no longer apply to the destination, whether it is written as a name or as an address. A socks5h, http or https upstream receives the name and resolves it at the far end. A socks5 upstream has the name resolved here, but only to choose an IPv4 address to ask for; the address itself is not judged. Either way, a request for a private or reserved address is passed to the exit rather than refused, and what happens to it is the exit’s decision.

Two rules still hold whatever the upstream is, because they are applied before the request is dispatched at all: an IPv6 literal is refused with 403, and a malformed authority is refused with 400. With a socks5 upstream a third holds, because the name is resolved here: a name that answers only with IPv6 addresses is refused with 403, while any IPv4 answer, private or not, is passed to the exit. A name that does not resolve at all is a connection failure rather than a refusal, and returns 502. The upstream’s own address is checked separately, under Accepted upstream addresses.

When the exit is your own machine, hoody egress local start applies its own destination rules there. They are described under Exit from your own machine, and they are the ones that decide what that chain can reach.

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 -H 'Content-Type: text/plain' --data-binary 'socks5h://user:pass@proxy.example.com:1080' \
"$EGRESS/api/v1/egress/upstream"
{
"enabled": true,
"state": "active",
"scheme": "socks5h",
"host": "proxy.example.com",
"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.

Add ?lease=<seconds> to set the upstream for a limited time: the service disables it once the lease runs out unless it is renewed with POST /api/v1/egress/upstream/renew. The lease is whole seconds from 5 to 3600; any other value is refused with 400 (Invalid lease: give whole seconds from 5 to 3600).

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.

The upstream address goes through the same check as a destination, so it has to be public IPv4. One exception exists for chaining: a socks5 or socks5h upstream on IPv4 loopback is accepted on any port, with or without credentials, because that is the shape hoody egress local start installs. An http or https upstream at loopback is refused, as is every IPv6 upstream, socks5h://[::1]:1080 included.

Request bodies are limited to 4096 bytes. A request without Content-Length returns 411 and a larger body returns 413. A 400 carries a plain-text body saying which check the URL failed:

BodyMeaning
Invalid upstream URLThe first non-comment line did not parse as a URL, or used a scheme outside the four above. A missing :// is the usual cause
Upstream destination not permittedThe URL parsed, but its address is refused by the destination rules
Upstream unresolvableThe host did not resolve within the connect timeout. Retry
Failed to read bodyThe body could not be read
Body must be UTF-8The body was not valid UTF-8

A refused or unparseable upstream offered while one is already working changes nothing, whether it arrives through the API or as a line in the config file: the working upstream stays in force and keeps carrying traffic. Send DELETE first if you want to stop using it.

GET /api/v1/egress/upstream reports two separate things. enabled says whether an upstream is configured. state says what the proxy is doing with it:

stateWhat it means
directNo upstream is configured. Requests exit from the container’s own IP
activeAn address for the upstream has been checked and approved, and requests go through it
unavailableAn upstream is configured but has no approved address, so proxied requests are refused

active describes the configuration rather than reachability. An upstream whose address is permitted but has nothing listening on it stays active while requests fail.

An upstream reads unavailable when none is working and the one that is configured was refused by the destination rules, did not resolve, or does not parse as a URL. Proxied requests then return 502 with the body configured upstream proxy is unavailable, and the destination is neither resolved nor dialled. Traffic is never sent out directly instead, so a request cannot leave from the container’s own address after you asked for an exit. The proxy keeps retrying in the background, so a name that starts resolving again recovers without being re-set. Direct egress resumes only when you disable the upstream: a DELETE, a PUT whose body is empty or the word disabled, or emptying a config file that has governed the setting.

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 takes a URL and nothing else. A value that is not one counts as unusable upstream intent rather than a filename, so the proxy refuses proxied requests until it is corrected or the upstream is explicitly disabled. The file form is --upstream-config, which is what the daemon program definition points 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.

An entry in the file that the destination rules refuse, or one that does not parse as a URL, never puts the container back on direct egress. What it does instead depends on what is already running. While an upstream is working, the bad entry is ignored and that upstream stays in force. With none working, the service reports "state": "unavailable" and answers proxied requests with 502 until the entry is fixed or removed.

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 start
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 start --allow-ports 80,443,8080
hoody --container CONTAINER egress local start --allow-ports '*'
hoody --container CONTAINER egress local start --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 start --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 disable

For the same reason the command refuses to start on a container that already has an upstream set, including one currently reported as unavailable. 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);
if (!data?.id) throw new Error('Container not found');
const handle = await startLocalExit({
client,
container: { id: data.id, project_id: data.project_id, server_name: data.server_name ?? undefined },
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';
let exit;
try {
exit = await startLocalExit({
client,
container: { id: data.id, project_id: data.project_id, server_name: data.server_name ?? undefined },
});
} 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 start --alias creates belongs to that command and is deleted when it stops.

Terminal window
hoody proxy aliases 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.

The credential that admits a request does not reach its destination. For a plain HTTP request sent through the proxy (an absolute-URI request such as GET http://example.com/), the edge removes every credential it verified before forwarding: the Authorization header, plus any token or JWT carried in a header, cookie, or query parameter that matched a permission rule. If the destination needs its own authentication, send it under a different header, cookie, or parameter name than the one that gates egress. A CONNECT request’s headers stop at the edge and are never written into the tunnel.

The proxy itself has five bounds. A Hoody container starts hoody-egress with only its host, port and upstream config path, so the defaults below are the live values.

FlagDefaultWhat it bounds
--max-connections1024Concurrent client connections to the proxy
--max-header-bytes16384Bytes read while waiting for the end of the request headers, before the request is rejected
--read-timeout-ms10000How long those initial headers may take to arrive
--connect-timeout-ms10000How long an outbound connection may take to establish
--idle-timeout-secs300How long a tunnelled connection may carry no data before it is closed. A connection that is still transferring is never interrupted

These are the proxy’s own limits and are separate from the exit command’s --max-concurrent, which bounds the far end of the chain.

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 alongside state, which separates an upstream that is carrying traffic from one 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. "state": "direct" means no upstream is set and the service dials destinations itself. A configured upstream never falls back to this: when it cannot be used it reports "state": "unavailable" and refuses the request instead.

Requests return 502 configured upstream proxy is unavailable. An upstream is configured but has no approved address, so the request is refused rather than sent out directly. GET /api/v1/egress/upstream shows which host is configured. Fix the address, or send DELETE to go back to direct egress.

A request returns 403 destination not permitted. The destination resolved to private, loopback, link-local, CGNAT or reserved space, or it is an IPv6 address and this service dials IPv4 only. Use a public IPv4 destination. There is no override for either rule.

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 response body says which check failed: Invalid upstream URL, Upstream destination not permitted, Upstream unresolvable, Failed to read body or Body must be UTF-8. See Accepted upstream addresses.

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.