Exposing pond (TLS, public URL, behind NAT)

By default the compose stack serves the control plane as plain HTTP on localhost. This guide makes it reachable — with TLS, from another machine, or from behind NAT — without any third-party SaaS. The TLS front door / Cloudflare Tunnel are opt-in env vars install.sh reads; a relay is attached after install with pond relay attach (or cast relay attach). Leave them all off and nothing changes.

Two questions decide what you need:

  • Does this host have a public IP (or can open :80/:443)? Tier 1 (a bundled Caddy front door).
  • Is it behind NAT with no inbound? Tier 2 (dial out to a relay).

And one more, orthogonal:

  • Remote consumer, workers stay local (the common case) expose only the control plane. Scenario A.
  • Workers run on other machines also expose the orchestrator + bundle store. Scenario B (pond relay attach --workers).

Network-posture matrix

Exposing the control plane means giving this host an inbound path. Pick by how restrictive the network between you and the world is:

Network postureModeKey setting
Public IP, can open :80/:443Tier 1 front door (Caddy)POND_DOMAIN
Behind NAT, but normal outboundTier 1 dns01, or Tier 2 relay (raw TCP :7000)POND_TLS_MODE=dns01 / pond relay attach <host>
Prefer no VM, OK with a SaaS in pathCloudflare TunnelPOND_CF_TUNNEL_TOKEN
443-only egress / corporate DPI / TLS-MITMTier 2 relay over wss-443pond relay attach <host> --protocol wss
+ a forced corporate HTTP proxywss-443 through the proxyPOND_RELAY_PROXY_URL
Even wss-443 is blockedsecond-vendor escape hatchTailscale Funnel / ngrok (see below)

Two things to keep straight:

  • Cloudflare Tunnel needs outbound :7844 (QUIC/TCP) and the raw-TCP relay needs outbound :7000 — both are dropped by 443-only / DPI firewalls. The wss-443 relay mode exists precisely for those networks: it tunnels the frp control connection as a WebSocket over the relay’s :443, so it looks like ordinary HTTPS.
  • Workers behind a firewall already work, no exposure needed. A worker opens no inbound port; it only dials outbound 443 to three places — the orchestrator, the bundle store (POND_BUNDLE_PUBLIC_ENDPOINT_URL), and the model API via the in-sandbox broker. So “run agents inside our locked-down network” needs nothing from this guide — only exposing a control plane from a restrictive network does.

Tier 1 — your own subdomain (public IP)

A Caddy service terminates TLS for your domain and reverse-proxies the control plane. The plaintext port drops to loopback-only; Caddy is the sole ingress.

# DNS: point an A/AAAA record for pond.acme.com at this host first.
POND_DOMAIN=pond.acme.com ./install.sh          # → https://pond.acme.com

Three TLS modes via POND_TLS_MODE:

ModeWhenNeeds
acme (default)host is internet-reachable on :80/:443nothing — automatic Let’s Encrypt
dns01behind NAT but you own the domaina caddy-dns image (POND_CADDY_IMAGE) + POND_DNS_PROVIDER + POND_DNS_API_TOKEN
byoyou have a cert (real, internal-CA, or self-signed)cert.pem+key.pem in POND_TLS_CERT_DIR
# bring-your-own cert
POND_DOMAIN=pond.acme.com POND_TLS_MODE=byo POND_TLS_CERT_DIR=/etc/pond/tls ./install.sh

Tier 2 — behind NAT (no inbound)

The host dials out to get a public URL — no open ports, no cert here. Two options, depending on whether you’d rather avoid a VM or avoid a third party in the path:

Option A — Cloudflare Tunnel (no VM)

cloudflared dials out to Cloudflare’s edge, which routes your hostname back down it. Cloudflare IS the relay — no VM, no extra bill (free tier). The trade-off: Cloudflare terminates TLS, so it sees /v1 traffic. Recommended when you’d rather not run a box.

# After creating a tunnel + public hostname in the Cloudflare dashboard:
POND_CF_TUNNEL_TOKEN=<tunnel token> \
POND_CF_HOSTNAME=acme.pond.neotype.io \
./install.sh                                     # → https://acme.pond.neotype.io

Full setup (dashboard steps + multi-tenant): deploy/cloudflared/.

Option B — self-hosted relay (no third party in the path)

Dial out to a relay VM you run, so traffic never transits a third party. The relay terminates TLS on infrastructure you own. Attach is a post-install CLI step — install the stack first (./install.sh), then:

pond relay attach relay.neotype.io --domain pond.neotype.io   # → https://p<hash>.pond.neotype.io
# dev (host-process) stack instead:  cast relay attach relay.neotype.io --domain pond.neotype.io

No token to obtain from the operator: pond relay attach autogenerates a per-tenant key and derives your subdomain from it (p<sha256(key)[:20]>), which the relay binds so no one else can claim it.

Running your own relay is the operator-side pond relay serve (mirror of attach), run on the relay VM — idempotent, so you reconfigure by re-running with new flags:

pond relay serve --domain pond.example.com \
  --dns-provider cloudflare --dns-module github.com/caddy-dns/cloudflare --dns-token <token>
pond relay serve --domain pond.example.com --tls on-demand        # no DNS token (per-name certs)
pond relay serve --domain pond.example.com --tls on-demand --wss  # + wss for restrictive tenants

--tls on-demand drops the DNS-01 token entirely (only a wildcard A record + :80 are needed); Caddy issues a cert per tenant subdomain on first hit, gated by the relay so only live tenants get one. Or use a relay someone else runs. Full setup (relay-install.sh no-CLI fallback included): deploy/relay/.

Option C — relay over wss-443 (restrictive / DPI / TLS-MITM networks)

When the Pond host sits on a network that only allows outbound 443 — corporate egress allowlists, deep-packet inspection, or a TLS-intercepting proxy (e.g. ESET) — both Cloudflare Tunnel (:7844) and the raw-TCP relay (:7000) are blocked. This mode tunnels the same relay over a WebSocket on the relay’s :443, which the firewall sees as ordinary HTTPS.

It’s the Option-B relay with one extra flag. The relay operator brings the relay up with RELAY_WSS=true (one toggle — see deploy/relay/), then a tenant on a restrictive network attaches with:

pond relay attach wss.pond.neotype.io --protocol wss --domain pond.neotype.io
#   → https://p<hash>.pond.neotype.io

--protocol wss dials :443 automatically. Note the relay host is the wss host (RELAY_WSS_HOST, default wss.<relay-domain> — under the wildcard, so it reuses the relay’s existing *.<relay-domain> cert), while --domain stays the tenant URL domain. If the network forces an explicit proxy, set POND_RELAY_PROXY_URL=http://proxy.corp:8080 (HTTP CONNECT / SOCKS5) in .env before attaching.

If you also use a credentialed proxy, prefer a credential-less POND_RELAY_PROXY_URL — frpc may echo the dial target into its logs, so embedded user:pass@ could surface in docker compose logs frpc.

Trust note. The wss TLS is transport obfuscation to pass DPI, not the trust boundary — by default the relay’s cert is not verified (a TLS-MITM re-sign is accepted). Pond’s agent-path confidentiality is app-layer and unaffected: model creds are sealed to the worker, source bundles are envelope-encrypted, and results are signed and verified. But two things ride this transport in the clear of any app-layer encryption: (1) the per-tenant POND_RELAY_TENANT_KEY, and (2) the inbound consumer /v1 bearer (pond_pk_… / POND_SERVICE_TOKEN) once a tenant subdomain is live. On a skip-verify path an active MITM can read both off the wire — capturing the tenant key lets it hijack that one tenant’s subdomain (its scope is a single tenant, and it still can’t read run data) and intercept those inbound /v1 credentials. The only real defense on an untrusted segment is to pin the relay CA with POND_RELAY_TLS_TRUSTED_CA (+ POND_RELAY_TLS_SERVERNAME), which turns verification back on; otherwise treat the transport as untrusted by design and rely on the app-layer guarantees plus a trusted relay segment.

DPI caveat. frp ≤ v0.69.1 (the default snowdreamtech/frpc image) sends wss control payloads as WebSocket text frames; a strict frame-validating DPI may still drop them. If that happens, pin a newer frp via POND_FRPC_IMAGE / RELAY_FRPS_IMAGE, or use the escape hatch below.

Scenario B — off-host workers over the tunnel/relay

To let workers on other machines claim jobs, also expose the orchestrator and the (encrypted) bundle store:

pond relay attach pond.neotype.io --workers

The control plane must presign the bundle download URL against the public store host (uploads stay internal — SigV4 signs the host, so the presign must match what the worker fetches), via POND_BUNDLE_PUBLIC_ENDPOINT_URL. The self-hosted relay (POND_RELAY_WORKERS=true) derives it for you; with Cloudflare Tunnel you add orch-…/s3-… public hostnames in the dashboard and set POND_BUNDLE_PUBLIC_ENDPOINT_URL=https://s3-acme.pond.neotype.io yourself. Then attach a worker from anywhere:

python swarm/swarm.py worker \
  --orchestrator https://orch-acme.pond.neotype.io \
  --token <POND_POOL_TOKEN> --capabilities harness.codex,sandbox.docker --repo-root /tmp/pondwork

Bundles are ChaCha20Poly1305-sealed per worker, so exposing the store is safe.

When even wss-443 is blocked

A few networks defeat everything above — they block all non-allowlisted destinations, or run EDR that quarantines frp itself as a hacktool. For those, the only thing that gets through is a tool purpose-built to tunnel over 443 and survive MITM, which means accepting a third party in the path:

  • Tailscale Funnel — publishes a local port to the public internet over its 443/DERP relays; works behind almost any firewall. You get a *.ts.net hostname (not your domain) and Tailscale is in the path.
  • ngrok — same idea, 443-based, MITM-tolerant.

These are an explicit escape hatch, not a first-class Pond mode — they add a dependency Pond otherwise avoids. Reach for them only when Option C can’t connect. (If only your workers are on such a network, you don’t need any of this — see the posture matrix: workers are pure outbound-443 and need no exposure.)

What gets set under the hood

install.sh (Tier 1 front door / Cloudflare Tunnel) and pond relay attach (Tier 2 relay) derive these into .env (so you don’t have to):

VarTier 1 / Tier 2Why
POND_FORWARDED_ALLOW_IPS=*bothtrust X-Forwarded-Proto/For from the proxy (uvicorn)
POND_CP_HOST=127.0.0.1bothplaintext control-plane port loopback-only
POND_PUBLIC_URL, CORS_ORIGINSbothadvertise the real https:// origin
POND_RELAY_TENANT_KEY, POND_SUBDOMAINTier 2the per-tenant key + its derived subdomain (pond relay attach)
POND_BUNDLE_PUBLIC_ENDPOINT_URLTier 2 + workerspresign bundle URLs for the public store host
POND_RELAY_PORT=443Tier 2 wss--protocol wss dials the relay’s :443 (flips a stale :7000)

Compose profiles gate the new services (frontdoor Caddy, cftunnel cloudflared, relay frpc), so a plain docker compose up with no exposure vars is byte-for-byte unchanged.

k8s? The Helm chart already does ingress + cert-manager TLS — see deploy/. This guide is the docker-compose equivalent.