The Hoody Proxy
Section titled “The Hoody Proxy”Every URL in Hoody passes through one gateway. The Hoody Proxy is not a load balancer, a CDN, or a conventional reverse proxy: it is the single point that turns URL requests into container service calls, enforces security, terminates TLS, and preserves the real client IP, with no configuration on your side.
When you access a terminal, a desktop, a file, a database, or any other service in any container, the request goes through the proxy. You never interact with the proxy directly, but every request shares one protocol, one gateway, and one audit trail.
How routing works
Section titled “How routing works”When a request arrives, the proxy does four things:
1. Parse the URL
Section titled “1. Parse the URL”https://67e89abc123def456789abcd-890abcdef12345678901cdef-terminal-1.node-us.containers.hoody.com/api/v1/terminal/execute └──────────┬──────────┘ └──────────┬──────────┘ └───┬───┘ └┘ Project ID Container ID Service InstanceThe proxy extracts four identifiers from the hostname: project ID, container ID, service type, and instance number. There is no routing table and no configuration file; the hostname itself is the route.
2. Locate the container
Section titled “2. Locate the container”The proxy maintains a live map of all containers on the server. Given the container ID, it knows the container’s internal IP, the ports each service listens on, and whether the container is running. If the container does not exist or is stopped, the proxy returns 503 Service Unavailable.
3. Check permissions
Section titled “3. Check permissions”If the container or its project has permissions configured, the proxy validates the request against every active authentication group: JWT claims, HTTP Basic credentials, client IP range, or bearer token. If no permissions are configured, the request passes; the cryptographic URL itself is the authentication.
4. Dispatch to the service
Section titled “4. Dispatch to the service”The proxy forwards the request to the correct internal service port inside the container. Terminal requests go to port 76. Display requests go to port 3998, the centralized display server (individual display sessions are allocated from a pool starting at 4000). SQLite goes to 5. (These internal Kit ports are operator-configurable defaults and never client-visible.) Your own HTTP servers go to whatever port you specified in the URL (http-3000, http-5000).
The client never sees internal ports; every request arrives as HTTPS on port 443.
Client Request ↓https://{projectId}-{containerId}-terminal-1.node-us.containers.hoody.com ↓Hoody Proxy (port 443) ├─ TLS termination ├─ URL parsing → project, container, service, instance ├─ Permission check (if configured) ├─ Real IP preservation (netfilter hooks) └─ Forward to container internal port 76 ↓Container's Terminal Service ↓Response → Proxy → ClientWildcard TLS
Section titled “Wildcard TLS”The proxy terminates TLS for every request using wildcard certificates:
*.{serverName}.containers.hoody.com # e.g. *.node-us.containers.hoody.comThis means:
- HTTPS everywhere. Every container service URL is HTTPS, with no HTTP fallback and no mixed-content warnings.
- No certificate management. You never generate, install, or renew a certificate.
- Private URLs. Certificate Transparency logs only ever see the per-server wildcard name (
*.node-us.containers.hoody.com); your specific container URLs are never published. - Custom domains. Point a CNAME to a proxy alias and Let’s Encrypt issues a certificate for it automatically.
Protocol support
Section titled “Protocol support”The proxy is not limited to basic HTTP request-response; it carries the rest of the modern web stack:
HTTP/1.1 and HTTP/2
Section titled “HTTP/1.1 and HTTP/2”The proxy negotiates the best available protocol with each client and handles the upgrade and fallback automatically. HTTP/2 multiplexing eliminates head-of-line blocking.
WebSocket
Section titled “WebSocket”Terminal sessions, display streaming, and real-time services use WebSocket connections that upgrade from HTTP. The proxy handles WebSocket natively: persistent bidirectional connections run through the same URL, the same TLS certificate, and the same authentication layer as everything else.
Multiple WebSocket connections to the same service URL create multiplayer sessions automatically. When two people open the same terminal URL, each gets a WebSocket connection into the same terminal session.
Unsupported protocols
Section titled “Unsupported protocols”The proxy is HTTP-native. Non-HTTP protocols do not pass through it:
- UDP services (game servers, VoIP, custom UDP protocols) need direct access via IPv4 addresses or SSH
- All proxy traffic is HTTPS over TCP (HTTP/1.1 and HTTP/2)
Real client IP preservation
Section titled “Real client IP preservation”When traffic passes through a typical reverse proxy, the application sees the proxy’s IP address, not the client’s. The standard workaround is the X-Forwarded-For header, but applications must explicitly parse it, firewalls cannot use it, and legacy code ignores it entirely.
Hoody solves this at the kernel level. Custom netfilter hooks in the host kernel rewrite connection metadata so that traffic arriving at the container appears to originate from the real client IP. The container’s remoteAddr is the actual client address, with no headers to parse, no application changes, and no configuration.
# Your application sees the real client IP automatically# No configuration needed
# Verify with a simple Node.js serverhoody terminal sessions exec \ --command "node -e \" require('http').createServer((req, res) => { res.end('Your IP: ' + req.socket.remoteAddress); }).listen(3000); \"" \ -c $CONTAINER_ID
# Access via the proxy: the server logs the real client IP, not the proxy IP// Any web framework sees real IPs without special configuration// Expressapp.get('/', (req, res) => { console.log(req.connection.remoteAddress); // "203.0.113.50", the actual client, not "10.0.0.1"});
// Python Flask// request.remote_addr -> "203.0.113.50"
// PHP// $_SERVER['REMOTE_ADDR'] -> "203.0.113.50"
// Go// r.RemoteAddr -> "203.0.113.50:54321"# Standard iptables rules work with real client IPs# Inside the container:
# Allow only your office IPiptables -A INPUT -s 203.0.113.0/24 -j ACCEPT
# Block a known bad actoriptables -A INPUT -s 198.51.100.42 -j DROP
# Works correctly because the proxy preserves real IPs# via kernel-level netfilter hooksEvery application, language, framework, firewall rule, and legacy system sees the real client IP without modification. Access control, rate limiting, geo-routing, and analytics all work as if the proxy did not exist.
The permission model
Section titled “The permission model”The proxy is the single enforcement point for all access control; rules live at the proxy, not in the individual services, containers, or applications.
Open by default
Section titled “Open by default”When you create a container, its URLs are accessible to anyone who has them. That is less dangerous than it sounds: container IDs are 24 hex characters, which means 2^96 possible combinations, so brute-forcing a container URL at one billion attempts per second would take longer than the age of the universe.
The URL itself is a cryptographic secret. Sharing it deliberately grants access; keeping it private keeps the container private.
Layered authentication
Section titled “Layered authentication”When you need more than URL secrecy, the proxy supports layered authentication:
| Method | How it works | Best for |
|---|---|---|
| JWT | Token with claims validation | API consumers, AI agents |
| Password | HTTP Basic Auth (username/password) | Quick protection, internal tools |
| IP whitelist | Allow specific IPs or CIDR ranges | Office access, known servers |
| Bearer token | Custom token in Authorization header | Service-to-service communication |
Permissions can be set at two levels:
- Project level applies to every container in the project.
- Container level overrides project settings for specific containers.
And permissions are granular per service:
Terminal: execute allowed, but files: read-onlyDisplay: view allowed, but control deniedDatabase: query allowed, but modify denied# Set project-level proxy permissions (replaces the full config JSON)# replace requires an If-Match precondition (read file_version from GET first)hoody projects proxy permissions replace --project $PROJECT_ID \ --if-match file:v2 \ --groups office='{"type":"ip","range":"203.0.113.0/24"}' \ --permissions office='{"terminal":true,"files":true,"display":true}' \ --default denyimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
// Lock down a container for production// Reads use GET to obtain the current file_version, then PUT with If-Matchawait client.api.proxyPermissionsContainer.replace(containerId, { project: PROJECT_ID, container: containerId, groups: { office: { type: 'ip', range: '203.0.113.0/24' }, ci: { type: 'token', header: 'X-CI-Token', value: 'secret-production-token' } }, permissions: { office: { terminal: true, files: true }, ci: { terminal: true, exec: true } }, default: 'deny'}, { ifMatch: 'file:v2' });# Set container-level permissions# PUT requires an If-Match precondition (read file_version from GET first)curl -X PUT "https://api.hoody.com/api/v1/containers/$CONTAINER_ID/proxy/permissions" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "If-Match: file:v2" \ -H "Content-Type: application/json" \ -d '{ "project": "'$PROJECT_ID'", "container": "'$CONTAINER_ID'", "groups": { "office": { "type": "ip", "range": "203.0.113.0/24" }, "ci": { "type": "token", "header": "X-CI-Token", "value": "ci-secret-token" } }, "permissions": { "office": { "terminal": true, "display": true, "files": true }, "ci": { "terminal": true, "exec": true, "files": true } }, "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
Replaces CONTAINER_ID’s whole permissions document, overriding the project-level one: grants the office IP range terminal, display, and file access, and the CI token terminal, exec, and file access. Route the link through a different running container’s curl-1 (OTHER_CONTAINER_ID below) — neither group is granted curl, so once this document is in place, CONTAINER_ID’s own curl-1 would deny the request that applied it. Fetch the current document first and put its file_version in place of file:v2, or the call is rejected.
https://PROJECT_ID-OTHER_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=PUT&bearer_token=HOODY_TOKEN&header=If-Match:%20file:v2&json={"project":"PROJECT_ID","container":"CONTAINER_ID","groups":{"office":{"type":"ip","range":"203.0.113.0/24"},"ci":{"type":"token","header":"X-CI-Token","value":"ci-secret-token"}},"permissions":{"office":{"terminal":true,"display":true,"files":true},"ci":{"terminal":true,"exec":true,"files":true}},"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.
Proxy aliases
Section titled “Proxy aliases”Cryptographic URLs are secure but unwieldy. Proxy aliases give containers human-friendly addresses:
https://my-api.node-us.containers.hoody.comAn alias maps to a specific container and service. Multiple aliases can point to the same container. Aliases support custom domains via CNAME records with automatic SSL:
api.yourcompany.com CNAME my-api.node-us.containers.hoody.comThe proxy handles the certificate, routes the request, and enforces permissions; your only step is updating the DNS record.
# Create an alias for your HTTP servicehoody proxy create \ --container-id $CONTAINER_ID \ --alias my-api \ --program http \ --port 3000import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.proxyAliases.create({ container_id: containerId, alias: 'my-api', program: 'http', port: 3000});
// https://my-api.node-us.containers.hoody.com -> container's HTTP service# Create a proxy aliascurl -X POST "https://api.hoody.com/api/v1/proxy/aliases" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "container_id": "890abcdef12345678901cdef", "alias": "my-api", "program": "http", "port": 3000 }'
# Result: https://my-api.node-us.containers.hoody.com# routes to your container's HTTP serviceOne 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 my-api alias in one GET; visiting it routes my-api.node-us.containers.hoody.com to the container’s HTTP service on port 3000.
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=HOODY_TOKEN&json={"container_id":"890abcdef12345678901cdef","alias":"my-api","program":"http","port":3000}&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.
Per-server architecture
Section titled “Per-server architecture”Each bare metal server runs its own Hoody Proxy container. The proxy is not a centralized service; it is local infrastructure on every server.
Server 1 (node-us) └─ Hoody Proxy Container └─ Routes to all containers on Server 1 URLs: *.node-us.containers.hoody.com
Server 2 (node-eu) └─ Hoody Proxy Container └─ Routes to all containers on Server 2 URLs: *.node-eu.containers.hoody.comRunning the proxy on each server has four consequences:
- Latency: proxy and containers are on the same machine, with no extra network hop.
- Privacy: container traffic never leaves your server; the proxy runs on your bare metal, not on Hoody’s.
- Reliability: each server is independent, so there is no centralized proxy to fail.
- Locality: the server name in the URL (
node-us,node-eu) tells you which proxy handles it.
Cross-server communication happens via public URLs: a container on node-us calls a container on node-eu through node-eu’s proxy, with the same protocol and security as any other request.
The single security enforcement point
Section titled “The single security enforcement point”All security decisions happen at the proxy, not at the service level, in application code, or in scattered per-service configuration files. Centralizing them is the decision that keeps the security model simple. One place handles:
- Authentication: every request, service, and container
- Authorization: per-service, per-group, per-container granularity
- Encryption: TLS termination for all traffic
- Logging: every request flows through one gateway
- Rate limiting: one enforcement point for all services
- Observation: matched HTTP requests routed through your own hoody-exec scripts with proxy hooks
Auditing your security posture means auditing the proxy configuration. Locking down production or opening access for a demo is a change to the proxy alone.
What the proxy enables
Section titled “What the proxy enables”The rest of Hoody’s architecture depends on the proxy:
- “Everything is a URL” works because the proxy routes every URL to the right container and service.
- Multiplayer works because the proxy handles concurrent WebSocket connections to the same service.
- Embeddability works because the proxy serves every service over HTTPS, which makes them safe to embed in an iframe.
- AI access works because the proxy speaks HTTP, a protocol AI tooling already understands.
- Custom domains work because the proxy terminates TLS and issues certificates.
- Security works because the proxy is the single enforcement point.
Without the proxy, URLs stop working, each service needs its own protocol and client (SSH, VNC, FTP, and a dozen others), and Hoody becomes an ordinary VM host. The proxy is what turns containers into URLs.
Next: Security & Permissions covers the full security model.