Skip to content

TCP vs TLS Ports

exposePort(port, { protocol: "tcp" }) and exposePort(port, { protocol: "tls" }) both publish a sandbox-internal port through caddy-l4, but they listen and route very differently. This page is a deeper explainer of the two modes - when to pick each, what they cost, and why the API gives you a choice.

For the API surface itself, see TCP & TLS Ports.

caddy-l4 binds one host port from the configured [22000, 23000] pool per exposure and forwards bytes 1:1 into the container.

client ──► public-host:37412 (caddy-l4) ──► container:5432
▲ ▲
bytes go through unchanged
  • URL shape: tcp://<public-host>:<host-port>
  • Result also carries host and hostPort as separate fields, so you can build a DSN without parsing the URL.
  • The proxy is protocol-agnostic - it doesn't read the bytes, just shovels them.
  • One TCP exposure consumes one host port in the pool, and one inbound firewall rule on that port.
  • Works for any TCP protocol, regardless of whether it speaks TLS.

caddy-l4 binds one shared port (typically :443), reads the SNI extension from the ClientHello to pick a route, and terminates TLS at the edge using the wildcard certificate Caddy already manages for *.<domain>. After termination, plaintext bytes are forwarded to the container on its native port.

client ──TLS──► public-host:443 (caddy-l4)
│ terminate TLS using the wildcard cert
│ Caddy auto-issued for *.<domain>
┌────────────┐
│ SNI= │
│ abc-5432.. │ ──plaintext──► sandbox abc, container:5432
│ xyz-6379.. │ ──plaintext──► sandbox xyz, container:6379
└────────────┘
many backends, one shared port, one wildcard cert
  • URL shape: tls://<id>-<port>.<domain>:<l4-port>
  • TLS is terminated at caddy-l4 using Caddy's existing wildcard cert. The container speaks plaintext TCP - it does not need to hold a cert, manage renewals, or know anything about TLS.
  • Many exposures share the same listener and the same cert - no host-port pool burn, no per-service firewall hole, no cert provisioning per sandbox.
  • Caddy's private key never leaves the host. Containers see only the decrypted bytes.
  • Requires the client to send SNI. If your client does not send SNI (very rare in 2026 - basically only ancient legacy clients), use protocol: "tcp" instead.
  • Not for mTLS / client-cert auth / custom ALPN selection by the backend. Those need access to the original TLS handshake and require raw TCP - see the trade-offs section.
WorkloadRecommended modeWhy
Postgres (plaintext)TCPDefault Postgres setups are plaintext; simplest and works with any client.
Postgres (TLS)TLS-SNIlibpq has sent SNI since v17. Saves a firewall hole; reuses :443.
RedisTCPRedis TLS support is rarely deployed; plain TCP dominates.
MySQL / MariaDBTCPMost clients skip TLS for ephemeral test data.
MongoDBTCPPlain mongo wire protocol over TCP is the default.
MQTT brokerTCP for mqtt://, TLS-SNI for mqtts://Match the client's URI scheme.
gRPC plaintext / h2cTCPNo TLS, no SNI, just bytes.
gRPC over TLSTLS-SNIgRPC clients send SNI; backend holds the cert.
HTTPS-shaped TCP behind a CDNTLS-SNILooks identical to a normal HTTPS service from outside.
SSH bastion / Git serverTCPSSH does not speak SNI. TLS-SNI cannot route it.
Dedicated game serverTCPGame protocols are bespoke.
Custom binary protocol (no TLS)TCPSame reason as game servers.
  • The protocol does not speak TLS, or
  • You don't want the sandbox to manage a TLS certificate, or
  • The client expects a <host>:<port> connection string (psql, redis-cli, mongosh).

You pay: one host port + one firewall rule per exposure, and a random high port number visible in 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
}
  • The client sends SNI (libpq, browsers, gRPC, modern Mongo / Redis / MySQL drivers all do), and
  • The backend speaks plain TCP and you want clients to see TLS, and
  • You want many backends sharing :443 so the URL looks like a normal HTTPS service.

You pay: only what you give up by terminating at the edge - mTLS, custom ALPN, and HTTP/2 client-cert auth no longer reach the backend. You save: no per-sandbox cert management, no host-port pool burn, no per-service firewall rule, no awkward high-port suffix in the URL.

const exposure = await sandbox.exposePort(5432, { protocol: "tls" });
// exposure.url === "tls://abc-5432.sandbox.example.com:443"
protocol: "tcp"protocol: "tls"
ListenerPer-exposure host port from [22000, 23000]Shared, default :443
URL shapetcp://<host>:<host-port>tls://<id>-<port>.<domain>:<l4-port>
Result fieldsurl, host, hostPorturl
Reads bytes?NoYes - terminates the TLS handshake
Terminates TLS?n/aYes, at caddy-l4 (using Caddy's wildcard)
Cert held byn/aCaddy on the host (private key never enters the container)
Backend speaksWhatever it likesPlaintext TCP
Supports mTLS / client certs?n/aNo (terminator strips them)
Client must send SNI?NoYes
Needs --domain?NoYes
Needs --dns-provider?NoYes - required by the installer whenever --domain is set
Firewall holes per exposure10 (shared listener)
Multiplexing modelOne server per host portOne server, many SNI routes

TCP is the answer for plain wire-protocol endpoints, and the only answer for protocols that need the original TLS handshake to reach the backend (mTLS, custom ALPN, HTTP/2 client-cert auth).

TLS-SNI is the answer for "I want a tls:// URL on :443 with a real cert, and my backend just speaks plain TCP."

If you're picking between them and not sure, default to TLS-SNI in domain mode - it gives you the friendly URL, the real cert, and zero per-firewall ports. Fall back to TCP if your client doesn't send SNI, your backend needs the raw handshake, or you're running in IP/path mode without --domain.

  • TCP & TLS Ports - the API surface, host-port pool, and reconcile guarantees.
  • Preview - HTTPS-shaped exposures via Caddy's HTTP reverse proxy.
  • Port Allowlist - explicit exposure before public traffic is accepted.