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.
| Mode | Call | Result fields | When |
|---|---|---|---|
| HTTP | exposePort(port) | url | browsers, REST APIs, websockets |
| Raw TCP | exposePort(port, { protocol: "tcp" }) | url, host, hostPort | Postgres, Redis, MySQL, Mongo, raw protocols |
| TLS-SNI | exposePort(port, { protocol: "tls" }) | url | TLS-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.
Raw TCP
Section titled “Raw TCP”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`;}exposure = sandbox.expose_port(5432, protocol="tcp")# exposure.protocol == "tcp"# exposure.url == "tcp://203.0.113.10:37412"# exposure.host == "203.0.113.10"# exposure.host_port == 37412dsn = f"postgresql://user:pass@{exposure.host}:{exposure.host_port}/appdb"exposure, err := sandbox.ExposePort(ctx, 5432, microvm.WithProtocol(sdktypes.ExposeProtocolTCP))if err != nil { log.Fatal(err)}// exposure.Protocol == "tcp"// exposure.PublicURL == "tcp://203.0.113.10:37412"// exposure.Host == "203.0.113.10"// exposure.HostPort == 37412dsn := fmt.Sprintf("postgresql://user:pass@%s:%d/appdb", exposure.Host, exposure.HostPort)let exposure = sandbox.expose_port(5432, ExposeOptions::tcp())?;if let ExposeResult::Tcp { url, host, host_port } = exposure { // url == "tcp://203.0.113.10:37412" let dsn = format!("postgresql://user:pass@{}:{}/appdb", host, host_port);}ExposeResult exposure = sandbox.exposePort(5432, ExposeOptions.tcp());// exposure.protocol == ExposeProtocol.TCP// exposure.url equals "tcp://203.0.113.10:37412"// exposure.host equals "203.0.113.10"// exposure.hostPort equals 37412String dsn = String.format("postgresql://user:pass@%s:%d/appdb", exposure.host, exposure.hostPort);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.
Host port pool
Section titled “Host port pool”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=22000SB_L4_PORT_RANGE_END=23000If 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.
What gets restored on restart
Section titled “What gets restored on restart”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.
TLS-SNI multiplexing
Section titled “TLS-SNI multiplexing”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"exposure = sandbox.expose_port(5432, protocol="tls")# exposure.url == "tls://abc-5432.sandbox.example.com:443"exposure, err := sandbox.ExposePort(ctx, 5432, microvm.WithProtocol(sdktypes.ExposeProtocolTLS))if err != nil { log.Fatal(err)}// exposure.PublicURL == "tls://abc-5432.sandbox.example.com:443"let exposure = sandbox.expose_port(5432, ExposeOptions::tls())?;if let ExposeResult::Tls { url } = exposure { // url == "tls://abc-5432.sandbox.example.com:443"}ExposeResult exposure = sandbox.exposePort(5432, ExposeOptions.tls());// exposure.url equals "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.
Reconciliation
Section titled “Reconciliation”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.
Unexposing
Section titled “Unexposing”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);sandbox.expose_port(3000)sandbox.expose_port(5432, protocol="tcp")sandbox.expose_port(6379, protocol="tls")
sandbox.unexpose_port(3000)sandbox.unexpose_port(5432)sandbox.unexpose_port(6379)if _, err := sandbox.ExposePort(ctx, 3000); err != nil { log.Fatal(err)}if _, err := sandbox.ExposePort(ctx, 5432, microvm.WithProtocol(sdktypes.ExposeProtocolTCP)); err != nil { log.Fatal(err)}if _, err := sandbox.ExposePort(ctx, 6379, microvm.WithProtocol(sdktypes.ExposeProtocolTLS)); err != nil { log.Fatal(err)}
_ = sandbox.UnexposePort(ctx, 3000)_ = sandbox.UnexposePort(ctx, 5432)_ = sandbox.UnexposePort(ctx, 6379)sandbox.expose_port(3000, ExposeOptions::default())?;sandbox.expose_port(5432, ExposeOptions::tcp())?;sandbox.expose_port(6379, ExposeOptions::tls())?;
sandbox.unexpose_port(3000)?;sandbox.unexpose_port(5432)?;sandbox.unexpose_port(6379)?;sandbox.exposePort(3000);sandbox.exposePort(5432, ExposeOptions.tcp());sandbox.exposePort(6379, ExposeOptions.tls());
sandbox.unexposePort(3000);sandbox.unexposePort(5432);sandbox.unexposePort(6379);What unexpose actually does
Section titled “What unexpose actually does”The daemon dispatches on the protocol stored on the exposed_ports row:
| Recorded protocol | Caddy surface torn down | Host-side cleanup |
|---|---|---|
http (or empty, for legacy rows) | DELETE /id/sandbox-<id>-port-<port> on the HTTP server | none |
tcp | DELETE /config/apps/layer4/servers/tcp-port-<host-port> | host port returns to the [22000, 23000] pool |
tls | DELETE /id/sandbox-<id>-port-<port>-tls on the SNI mux | none |
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.
Switching protocols on a port
Section titled “Switching protocols on a port”(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.
Related docs
Section titled “Related docs”- TCP vs TLS Ports - deeper picker for choosing between
protocol: "tcp"andprotocol: "tls". - Preview - HTTPS-shaped exposures.
- Port Allowlist - explicit exposure before public traffic is accepted.
- Reconcile - how caddy state is kept consistent with the database.