The Hoody Proxy
Section titled “The Hoody Proxy”Every URL you have used so far goes through the Hoody Proxy, the gateway between the outside world and your containers. It routes each request to the right service, enforces permissions, and preserves the real client IP. Every container service URL is HTTPS with HTTP/2 and HTTP/3 (QUIC); the proxy issues and renews the certificates, and there is nothing to configure.
What the proxy does
Section titled “What the proxy does”-
Automatic HTTPS: Wildcard TLS certificates cover every container URL. There is no Let’s Encrypt setup, certificate rotation, or DNS challenge.
-
URL routing: The proxy parses
{projectId}-{containerId}-{service}-{instance}.{serverName}.containers.hoody.comfor Kit services, and{projectId}-{containerId}-http-{port}for anything you started yourself, then routes to the right process inside the right container. -
Permission enforcement: Authentication (JWT, password, IP whitelist, bearer token) runs before any request reaches your container.
-
Real client IP: Netfilter hooks preserve the real client IP address, so your application sees the actual visitor, not a proxy address.
-
Protocol support: The proxy handles HTTP/1.1, HTTP/2, HTTP/3 (QUIC), and WebSocket upgrades; real-time terminals and displays run over WebSocket.
URL routing
Section titled “URL routing”When you hit:
https://PROJECT_ID-CONTAINER_ID-terminal-1.node-us-1.containers.hoody.com/api/v1/terminal/executeThe proxy:
- Extracts
PROJECT_ID(project),CONTAINER_ID(container),terminal-1(service + instance) - Looks up
node-us-1to find the physical server - Routes to
terminalservice instance1inside containerCONTAINER_ID - Forwards the request with real client IP preserved
The whole lookup takes milliseconds and requires no configuration on your part.
Port-based URLs
Section titled “Port-based URLs”Kit services are addressed by name; your own programs are addressed by port, and the proxy routes both. Start anything that listens on a TCP port, then put http-{port} where the service name would go.
# Start a dev server on port 3000 inside your containerhoody terminal sessions exec -c $CONTAINER_ID --terminal-id 1 \ --command "nohup python3 -m http.server 3000 >/tmp/dev.log 2>&1 &"
# It's already on the public internet, with HTTPScurl https://$PROJECT_ID-$CONTAINER_ID-http-3000.$SERVER_NAME.containers.hoody.com/// Start a dev server on port 3000 inside your containerawait box.terminal.execution.execute({ command: 'nohup python3 -m http.server 3000 >/tmp/dev.log 2>&1 &', wait: true}, { terminal_id: '1' });
// It's already on the public internet, with HTTPSconst url = `https://${PROJECT_ID}-${CONTAINER_ID}-http-3000.${SERVER_NAME}.containers.hoody.com/`;console.log(await (await fetch(url)).text());# Start a dev server on port 3000 inside your containercurl -X POST \ "https://$PROJECT_ID-$CONTAINER_ID-terminal-1.$SERVER_NAME.containers.hoody.com/api/v1/terminal/execute?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"command": "nohup python3 -m http.server 3000 >/tmp/dev.log 2>&1 &", "wait": true}'
# It's already on the public internet, with HTTPScurl https://$PROJECT_ID-$CONTAINER_ID-http-3000.$SERVER_NAME.containers.hoody.com/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
Starts a background dev server on port 3000, then fetches it over the container’s public http-3000 URL. Neither call carries a token; a fresh container’s URLs are open by default.
# Start dev server
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER_NAME.containers.hoody.com/api/v1/terminal/execute?terminal_id=1&method=POST&json={"command":"nohup%20python3%20-m%20http.server%203000%20>/tmp/dev.log%202>%261%20%26","wait":true}&response=transparent
# Fetch it
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-http-3000.SERVER_NAME.containers.hoody.com/&method=GET&response=transparent You do not write an EXPOSE directive, map a port, run an ingress controller, or obtain a certificate. The process starts listening and the URL starts working.
Any port from 1 to 65535 works, and one container can serve as many as you like at once:
https://{projectId}-{containerId}-http-3000.{serverName}.containers.hoody.com (frontend)https://{projectId}-{containerId}-http-5000.{serverName}.containers.hoody.com (backend)https://{projectId}-{containerId}-http-8000.{serverName}.containers.hoody.com (admin)http- versus https-
Section titled “http- versus https-”Either prefix gives your visitor HTTPS; that never changes. The prefix describes the inside leg, from the proxy to your process:
http-3000: the proxy speaks plain HTTP to your process. This is what you want for almost everything: your dev server doesn’t need a certificate, because the proxy already presented one.https-3000: the proxy opens a second TLS connection to your process. Use this only when your program is itself serving TLS on that port.
Point https- at a plaintext server and the handshake fails. That mismatch is the usual cause of a working port that won’t load.
Reserved ports
Section titled “Reserved ports”Kit services occupy their own ports inside every container, and a raw-port URL can’t be used to sneak past their permissions. Ask for a port that belongs to terminal, files, daemon, code, or sqlite and the request is judged by that service’s permission rule, not your http rule. The agent daemon and the shared display server are refused outright. Everything else (3000, 5000, 8080, whatever you picked) is yours.
Custom domain aliases
Section titled “Custom domain aliases”When you’d rather hand out a short, memorable URL than one stuffed with project and container IDs, create an alias. It shortens the URL; it does not hide the IDs from whoever visits it, because some programs return them in their responses. A name is 3-61 chars of a-z, 0-9, and hyphens; it cannot start or end with a hyphen; containers is reserved as an exact name; and names equal to or starting with egress / workspaces (e.g. egress-app) are rejected:
# Create a proxy aliashoody proxy create \ --container-id $CONTAINER_ID \ --program "exec" \ --alias "my-api" \ --index 1
# ...or alias your own app instead of a Kit servicehoody proxy create \ --container-id $CONTAINER_ID \ --program http \ --port 3000 \ --alias "my-app"const alias = await client.api.proxyAliases.create({ container_id: CONTAINER_ID, program: 'exec', alias: 'my-api', index: 1});// Now https://my-api.{server}.containers.hoody.com → your exec scriptscurl -X POST https://api.hoody.com/api/v1/proxy/aliases \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "container_id": "'$CONTAINER_ID'", "program": "exec", "alias": "my-api", "index": 1 }'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
Creates the alias my-api.{serverName}.containers.hoody.com, routed to the container’s exec scripts at index 1.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/proxy/aliases&method=POST&bearer_token=TOKEN&json={"container_id":"CONTAINER_ID","program":"exec","alias":"my-api","index":1}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Permissions
Section titled “Permissions”By default, container URLs are accessible by anyone who has the URL. The URL itself is unguessable (48+ characters of hex), which provides a baseline of security.
When you’re ready to lock things down:
# Require password authenticationhoody containers proxy permissions replace -c $CONTAINER_ID \ --if-match file:v<N> \ --project $PROJECT_ID \ --groups auth='{"type": "password", "username": "dev", "password": "my-secret", "algorithm": "sha256", "salt": "unique-salt"}' \ --permissions auth='{"terminal": true, "files": true, "display": true, "http": [8080]}'
# Restrict to specific IP addresseshoody containers proxy permissions replace -c $CONTAINER_ID \ --if-match file:v<N> \ --project $PROJECT_ID \ --groups office='{"type": "ip", "range": "203.0.113.10/32"}' \ --permissions office='{"terminal": true, "files": true, "display": true, "http": [8080]}'// Require password authawait client.api.proxyPermissionsContainer.replace(CONTAINER_ID, { project: PROJECT_ID, container: CONTAINER_ID, groups: { devs: { type: 'password', username: 'dev', password: 'my-secret', algorithm: 'sha256', salt: 'unique-salt' } }, permissions: { devs: { terminal: true, files: true, display: true, http: [8080] } }, default: 'deny'}, { ifMatch: 'file:v<N>' });
// IP restrictionawait client.api.proxyPermissionsContainer.replace(CONTAINER_ID, { project: PROJECT_ID, container: CONTAINER_ID, groups: { office_primary: { type: 'ip', range: '203.0.113.10/32' }, office_subnet: { type: 'ip', range: '198.51.100.0/24' } }, permissions: { office_primary: { terminal: true, files: true, display: true, http: [8080] }, office_subnet: { terminal: true, files: true, display: true, http: [8080] } }, default: 'deny'}, { ifMatch: 'file:v<N>' });# Set password authcurl -X PATCH https://api.hoody.com/api/v1/containers/$CONTAINER_ID/proxy/permissions \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "If-Match: file:v<N>" \ -d '{"project":"'$PROJECT_ID'","container":"'$CONTAINER_ID'","groups":{"devs":{"type":"password","username":"dev","password":"my-secret","algorithm":"sha256","salt":"unique-salt"}},"permissions":{"devs":{"terminal":true,"files":true,"display":true,"http":[8080]}},"default":"deny"}'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
Sets password authentication on the devs group with access to terminal, files, display, and port 8080. file:v<N> in If-Match must match the permissions document’s current version, fetched with a prior GET.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/containers/CONTAINER_ID/proxy/permissions&method=PATCH&bearer_token=TOKEN&header=If-Match:%20file:v<N>&json={"project":"PROJECT_ID","container":"CONTAINER_ID","groups":{"devs":{"type":"password","username":"dev","password":"my-secret","algorithm":"sha256","salt":"unique-salt"}},"permissions":{"devs":{"terminal":true,"files":true,"display":true,"http":[8080]}},"default":"deny"}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
One permissions document declares reusable auth groups (password, JWT, IP, token) and grants per-program access to them. Add a program in the permissions.<group>.<program> map to let that group in; anything not granted stays at the default policy. Configure once, apply it across whichever services need protection.
Open by default
Section titled “Open by default”URLs are unguessable, so knowing one is the only requirement for access. In practice:
- Development: no authentication between you and the container; you build against the URL directly.
- Collaboration: share the URL and a teammate has the same access you do.
- Production: add authentication when you’re ready.
Security configuration arrives when you need it rather than up front.
Next: Your First API →