How to Expose a Sandbox Port on a Public URL
This comes up the moment an agent starts building things rather than just computing them. The model writes a Next.js app, runs the dev server inside the sandbox, and reports success — and now a human needs to look at it. The server is listening on port 3000 inside a VM with a private address on an isolated network. From your laptop, it may as well not exist.
The same problem shows up in a dozen other shapes: a Streamlit dashboard an agent generated, a Jupyter server, an API you want to hit with Postman, a preview link for a reviewer. All of them need the same thing — a public HTTPS URL that reaches a port inside the sandbox.
How the URL works
On PandaStack the routing is host-based and requires no setup. A request to https://{port}-{sandbox_id}.pandastack.ai is rewritten to the per-sandbox port proxy and forwarded to that port inside the guest. The SDK will build the URL for you rather than making you assemble strings:
from pandastack import Sandbox
sb = Sandbox.create(template="base", ttl_seconds=3600)
# Start the server detached so exec returns immediately.
sb.exec("cd /app && setsid npm run dev > /tmp/dev.log 2>&1 &")
print(sb.preview_url(3000))
# https://3000-9c1f8a2e-....pandastack.ai
print(sb.preview_urls()) # every port the guest is currently listening on
# {3000: 'https://3000-9c1f...', 8000: 'https://8000-9c1f...'}No tunnel to configure, no port to reserve, no separate token endpoint to call. The URL exists for the lifetime of the sandbox and stops resolving when the sandbox goes away.
The one bug everyone hits
If the URL returns a connection error, the cause is almost certainly the bind address. A server listening on 127.0.0.1 is reachable only from inside the guest — the proxy is on the other side of the guest's network interface, so loopback is invisible to it. This is the same mistake that breaks Docker deployments, and it produces the same confusing symptom: it works when the agent curls it from inside, and fails from outside.
# Wrong — only the guest itself can reach this.
npm run dev # Next.js binds localhost by default
python -m http.server 8000 --bind 127.0.0.1
# Right — reachable from the proxy.
npm run dev -- --hostname 0.0.0.0
python -m http.server 8000 --bind 0.0.0.0
uvicorn app:app --host 0.0.0.0 --port 8000When you are debugging this, check from inside the guest first. If curl to 127.0.0.1:3000 works and curl to the guest's own network address does not, you have found it — no further investigation needed.
The security model, stated plainly
The preview URL is tokenless. Anyone who has the link can reach that port for as long as the sandbox lives. The sandbox UUID is the credential — a 128-bit random identifier that is not enumerable and not listed anywhere public, which is the same model most hosted sandbox platforms use and the reason preview links can be shared without a login dance.
That is a real trade, and it deserves to be a decision rather than a surprise. What it means in practice:
- A URL in a Slack channel is readable by everyone in that channel, and by anyone they forward it to. The link is the access control.
- Do not expose a port serving data that would matter if a stranger read it. If you need real authentication, put it in the app — the platform is not going to add a login page for you.
- The URL dies with the sandbox, so a short TTL is a genuine security control, not just a cost one. A preview that exists for an hour is a much smaller target than one that exists indefinitely.
- Sandboxes backing managed databases and hosted apps are deliberately excluded from this routing — the tokenless preview path is for sandboxes you created, not for infrastructure whose id you happen to know.
Waiting for the server to actually be up
Starting a process is instant; a server being ready to answer is not. If you hand a human the URL the moment exec returns, they will click into an error and tell you it is broken. Poll from inside the guest until the port answers, then share the link.
import time
def wait_for_port(sb, port: int, timeout: int = 90) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
res = sb.exec(f"curl -sf -o /dev/null http://127.0.0.1:{port}/ && echo up")
if "up" in (res.stdout or ""):
return
time.sleep(1)
# The log usually says exactly what went wrong — a port conflict,
# a missing dependency, or a crash on startup.
raise TimeoutError(sb.exec("tail -50 /tmp/dev.log").stdout)
wait_for_port(sb, 3000)
print("ready:", sb.preview_url(3000))Returning the log tail in the timeout is worth the extra line. Nine times out of ten it contains the actual cause, and without it you are left guessing at a server that silently did not start.
When you want an app, not a sandbox
Preview URLs are built for ephemeral things: an agent's output, a reviewer's look at a branch, a demo that lives for an afternoon. If what you actually have is a service that should stay up, survive restarts, redeploy when you push, and keep a stable address, a sandbox preview is the wrong primitive.
That is what app hosting is for — a git repo we build and run behind a URL that does not change across deploys, with health checks, blue-green flips, and rollback. The tell is whether the URL changing would upset anyone. If it would, you want an app.
Cleaning up, and why it matters more here
A sandbox with nothing exposed that outlives its usefulness costs you money. A sandbox with a public port that outlives its usefulness costs you money and leaves a reachable service running code nobody is watching. That is a meaningful difference, and it is worth a slightly stricter habit than you would apply to a compute-only sandbox.
Three things make this reliable. Set a TTL at creation that reflects how long the preview is genuinely useful — an hour for an agent's output, a working day for a review, rarely more. Delete the sandbox explicitly when the work finishes, in a finally block, rather than leaving it to the reaper. And if you generate previews programmatically, list your running sandboxes on a schedule and check that the count matches what you expect; a slow leak of exposed sandboxes is the kind of thing you notice on an invoice rather than in a log.
Self-hosted and local development
Host-based preview routing needs a wildcard DNS record and a matching certificate, so on a self-hosted deployment it is enabled by configuration rather than on by default. Point a wildcard at your control plane and set the preview host suffix, and the same SDK calls produce URLs on your domain — the SDK derives the suffix from the API URL, or you can override it explicitly.
Locally, where you have neither wildcard DNS nor a certificate, the authenticated proxy path through the API is the fallback: same destination, but you send your token instead of relying on the URL being unguessable. It is less convenient and strictly safer, which is the right default for a machine on your desk.
Frequently asked questions
Why does my preview URL return a connection error?
The server is almost certainly bound to 127.0.0.1. Loopback inside the guest is not reachable from the proxy, which sits on the other side of the guest's network interface, so the port looks closed from outside even though the process is running fine. Bind to 0.0.0.0 instead. The quick confirmation is to curl the port from inside the sandbox on both 127.0.0.1 and the guest's own address — if the first works and the second does not, that is your answer.
Is a tokenless preview URL safe to share?
It is safe in the sense that the sandbox id is a long random value nobody can guess or enumerate, and it stops working when the sandbox does. It is not safe in the sense of being access-controlled: whoever holds the link has access for as long as the sandbox lives. Share it the way you would share an unlisted document link — fine for a work-in-progress preview, wrong for anything you would not want forwarded. If you need identity, put authentication in the app itself.
How long does the URL stay valid?
For the sandbox's lifetime, which you control with the TTL you set at creation. That makes the TTL a security parameter as well as a cost one: a preview that exists for an hour is a far smaller exposure than one left running for a week because nobody cleaned it up. Set a TTL on every sandbox that serves a public port, and treat the explicit delete as the normal path with the TTL as the backstop.
Can I use my own domain for a preview?
For sandbox previews the host format is fixed, because the routing is derived from the hostname itself. Custom domains belong to app hosting, where an app can be attached to your own domain with certificates issued automatically. That split is deliberate: sandbox previews are ephemeral and want zero configuration, whereas a custom domain implies something permanent enough to justify DNS records — and if it is permanent, it should be an app rather than a sandbox.
Keep reading
- Sandboxes on PandaStack — microVM per sandbox, tokenless previews
- Preview environments
- Streaming command output from a sandbox
- Controlling sandbox lifetime with TTLs
- Adding a custom domain to an app
49ms p50 cold start. Fork, snapshot, and scale to zero.