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 posture | Mode | Key setting |
|---|---|---|
Public IP, can open :80/:443 | Tier 1 front door (Caddy) | POND_DOMAIN |
| Behind NAT, but normal outbound | Tier 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 path | Cloudflare Tunnel | POND_CF_TUNNEL_TOKEN |
| 443-only egress / corporate DPI / TLS-MITM | Tier 2 relay over wss-443 | pond relay attach <host> --protocol wss |
| + a forced corporate HTTP proxy | wss-443 through the proxy | POND_RELAY_PROXY_URL |
| Even wss-443 is blocked | second-vendor escape hatch | Tailscale 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:
| Mode | When | Needs |
|---|---|---|
acme (default) | host is internet-reachable on :80/:443 | nothing — automatic Let’s Encrypt |
dns01 | behind NAT but you own the domain | a caddy-dns image (POND_CADDY_IMAGE) + POND_DNS_PROVIDER + POND_DNS_API_TOKEN |
byo | you 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 embeddeduser:pass@could surface indocker 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 →/v1bearer (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/v1credentials. The only real defense on an untrusted segment is to pin the relay CA withPOND_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/frpcimage) sends wss control payloads as WebSocket text frames; a strict frame-validating DPI may still drop them. If that happens, pin a newer frp viaPOND_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.nethostname (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):
| Var | Tier 1 / Tier 2 | Why |
|---|---|---|
POND_FORWARDED_ALLOW_IPS=* | both | trust X-Forwarded-Proto/For from the proxy (uvicorn) |
POND_CP_HOST=127.0.0.1 | both | plaintext control-plane port → loopback-only |
POND_PUBLIC_URL, CORS_ORIGINS | both | advertise the real https:// origin |
POND_RELAY_TENANT_KEY, POND_SUBDOMAIN | Tier 2 | the per-tenant key + its derived subdomain (pond relay attach) |
POND_BUNDLE_PUBLIC_ENDPOINT_URL | Tier 2 + workers | presign bundle URLs for the public store host |
POND_RELAY_PORT=443 | Tier 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.