Skip to content

TCP & TLS Ports

Browser-shaped traffic is happy on a Caddy HTTP reverse proxy, but raw-protocol clients - Postgres, Redis, MySQL, MongoDB, MQTT, gRPC, SSH, dedicated game servers - speak the wire directly and need a real TCP listener. AerolVM exposes both, behind one SDK call:

exposePort(port, { protocol: "http" | "tcp" | "tls" })

protocol defaults to "http", which keeps the existing https://<sandbox-id>-<port>.<domain> route. Pass "tcp" to allocate a parent-host port via caddy-l4 and get a tcp://<host>:<host-port> URL that plugs straight into a native client. Pass "tls" to add a TLS-SNI route to the shared :443 listener.

The return value is a discriminated result keyed on protocol. Only the "tcp" variant carries host / hostPort - those are the dial coordinates a Postgres or Redis driver actually wants.

ModeCallResult fieldsWhen
HTTPexposePort(port)urlbrowsers, REST APIs, websockets
Raw TCPexposePort(port, { protocol: "tcp" })url, host, hostPortPostgres, Redis, MySQL, Mongo, raw protocols
TLS-SNIexposePort(port, { protocol: "tls" })urlTLS-aware backends that already speak SNI

All three share the same lifecycle: routes are torn down on stop / destroy and re-published on start, and a single unexposePort(port) tears down whichever surface the row was on.

Not sure which non-HTTP option fits your workload? See TCP vs TLS Ports for a side-by-side mental model, a workload-by-workload picker, and the trade-offs each mode imposes.

exposePort(port, { protocol: "tcp" }) allocates a parent-host port from the configured pool (default [22000, 23000]) and points caddy-l4 at the sandbox's container IP. The returned result has url in tcp://<public-host>:<host-port> form, plus host and hostPort as separate fields so you can build a DSN without parsing the URL.

const exposure = await sandbox.exposePort(5432, { protocol: "tcp" });
if (exposure.protocol === "tcp") {
// exposure.url === "tcp://203.0.113.10:37412"
// exposure.host === "203.0.113.10"
// exposure.hostPort === 37412
const dsn = `postgresql://user:pass@${exposure.host}:${exposure.hostPort}/appdb`;
}

The tuple (host, hostPort) is everything you need to build a DSN for any TCP protocol - pass it directly to psql, redis-cli, mysql, mongosh, or your application's connection string.

The allocator picks a candidate at random from [SB_L4_PORT_RANGE_START, SB_L4_PORT_RANGE_END] and reserves it via a single atomic INSERT OR IGNORE. Random-first keeps p95 fast even with many concurrent allocations; on collision it falls back to a deterministic linear scan so the pool is exhausted fully before giving up.

The default pool is [22000, 23000] - 1000 slots, which is the per-host cap on concurrent TCP exposures. The range is chosen to sit below the Linux ephemeral port range (net.ipv4.ip_local_port_range, typically 32768–60999) so the kernel never picks one of these as the source port for an unrelated outbound connection. Overlap there caused intermittent EADDRINUSE failures when the allocator's bind() raced with a kernel-chosen ephemeral source port; keeping the pool out of that range eliminates the failure mode entirely.

Override the pool with environment variables on sandboxd:

SB_L4_PORT_RANGE_START=22000
SB_L4_PORT_RANGE_END=23000

If you raise these bounds, keep both sides outside your kernel's ephemeral range - check it with cat /proc/sys/net/ipv4/ip_local_port_range. Picking values inside the ephemeral range will reintroduce the random-collision failure mode on busy hosts.

The host firewall must permit inbound traffic on that range. The installer prints a reminder, but does not auto-open the firewall - operators decide their own policy.

The host_port reservation is persisted alongside the exposure row. After a sandboxd restart, the periodic reconcile re-publishes the layer4 server pointing at the (possibly new) container IP, and the same host port stays bound - clients with cached connection strings keep working.

In cluster mode, the selected host port is also replicated in the placement route map. Non-owner nodes bind the same public host port and proxy to the current owner, so tcp://<cluster-host>:<host-port> remains the intended stable endpoint after route convergence. If a failover owner cannot bind the replicated host port because something local already owns it, the exposure needs operator attention rather than silently changing to a different port.

exposePort(port, { protocol: "tls" }) reuses one shared listener (default :443), terminates TLS at the edge using Caddy's wildcard certificate, and forwards plaintext bytes to the container. The result has url in tls://<id>-<port>.<domain>:<l4-port> form.

Your backend speaks plain TCP - no certificate management, no renewal, no private key inside the container. Caddy's wildcard cert lives on the host and never crosses the container boundary.

const exposure = await sandbox.exposePort(5432, { protocol: "tls" });
// exposure.protocol === "tls"
// exposure.url === "tls://abc-5432.sandbox.example.com:443"

Requirement: domain mode. --domain must be configured (the SNI hostname needs an A record pointing at the host). Domain-mode installs always run DNS-01 wildcard TLS - install.sh requires --dns-provider and --dns-api-token whenever --domain is set, so TLS-SNI multiplexing is always available. caddy-l4 owns :443 (SB_L4_TLS_LISTEN=:443) and the regular Caddy HTTPS listener is relocated to 127.0.0.1:8443. Caddy's wildcard cert for *.<domain> is issued once via DNS-01 at startup and reused for every TLS-SNI exposure.

TLS is terminated at the edge by caddy-l4. Caddy's auto-managed wildcard cert is presented to the client; bytes after the handshake are forwarded as plaintext to the container. This means:

  • No cert lives inside the container. Your sandbox process speaks plain TCP and is unaware that clients connected over TLS.
  • No per-sandbox cert work happens at expose time. The wildcard already exists; we just add an SNI route to the layer4 server. Expose latency is one Caddy admin API call plus one SQLite insert.
  • mTLS, custom ALPN, and HTTP/2 client-cert auth are not supported in this mode - the terminator consumes the original handshake. Use protocol: "tcp" if you need those.

Reconcile keeps caddy and the sandbox database in sync across all three protocols (http, tcp, tls). The periodic loop and POST /v1/admin/reconcile both:

  • Re-upsert routes for running sandboxes whose caddy entries are missing.
  • Drop routes when the underlying sandbox is destroyed.
  • Garbage-collect zombie caddy routes / layer4 servers whose IDs match our convention but no longer correspond to any DB row (e.g. after a destroyed-row TTL purge or development DB wipe).

See Reconcile for full details.

unexposePort(port) tears down whichever surface the port was published on - HTTP, TCP, or TLS. The daemon dispatches on the recorded protocol from the exposed_ports row, so the caller does not need to remember which protocol was used:

await sandbox.exposePort(3000); // -> { protocol: "http", url }
await sandbox.exposePort(5432, { protocol: "tcp" }); // -> { protocol: "tcp", url, host, hostPort }
await sandbox.exposePort(6379, { protocol: "tls" }); // -> { protocol: "tls", url }
// All tear down with the same call:
await sandbox.unexposePort(3000);
await sandbox.unexposePort(5432);
await sandbox.unexposePort(6379);

The daemon dispatches on the protocol stored on the exposed_ports row:

Recorded protocolCaddy surface torn downHost-side cleanup
http (or empty, for legacy rows)DELETE /id/sandbox-<id>-port-<port> on the HTTP servernone
tcpDELETE /config/apps/layer4/servers/tcp-port-<host-port>host port returns to the [22000, 23000] pool
tlsDELETE /id/sandbox-<id>-port-<port>-tls on the SNI muxnone

In every case the row in exposed_ports is deleted and the toolbox port allowlist is refreshed so subsequent calls to /proxy/<port>/ on the public sandbox URL are rejected immediately.

(sandbox_id, port) allows only one row at a time, so re-exposing the same port under a different protocol fails fast with port N already exposed as <proto>; unexpose it first. Call unexposePort(port) and then re-exposePort with the new options.

  • TCP vs TLS Ports - deeper picker for choosing between protocol: "tcp" and protocol: "tls".
  • Preview - HTTPS-shaped exposures.
  • Port Allowlist - explicit exposure before public traffic is accepted.
  • Reconcile - how caddy state is kept consistent with the database.