Custom domains, TCP ports, dedicated addresses and private networks
Six ways to connect sandboxes to the rest of your world and to each other. A
dedicated address needs credit on the account. The others need a kept top-up:
one paid and not refunded, because spammers abuse them. Without one, a request
returns payment_required (402) and says which (pricing).
A custom domain or a TCP port also needs the sandbox it serves to be a paid one.
- Custom domain: a sandbox's web port at your own hostname, with HTTPS.
- TCP port: a public
address:portfor anything that is not HTTP, such as Postgres, Redis, MQTT or a game server. - Dedicated outbound address: every sandbox in your account sends from one address of its own, so you can allow-list it.
- Private network: a WireGuard tunnel from your own network, in any cloud or
on your premises, into your sandboxes (
runtime networks wireguard). This is the only "tunnel" on this page.runtime sandboxes sshandport-forwardare a separate connection from your own computer (SSH and editors). - Sandboxes by name: your sandboxes reach each other at
<name>.sandbox.internal, free. It needs no tunnel. The CLI and MCP call itnetworks private(runtime networks private on). This is not the WireGuard private network above. - Your Tailscale network: a sandbox joins your tailnet as a machine of its own, with your auth key.
None of them belongs to one sandbox alone, so each is its own product in the CLI,
the SDKs and MCP: runtime domains, runtime ports, runtime ips and
runtime networks wireguard; runtime.domains, runtime.ports, runtime.ips
and runtime.networks.wireguard; runtime_domains_*, runtime_ports_*,
runtime_ips_* and runtime_networks_wireguard_*. A tailnet belongs to one
sandbox: runtime sandboxes tailscale, sbx.tailscale and
runtime_sandboxes_tailscale. A dedicated IPv4 address and a tunnel each cost
$5 per 30-day month, charged in proportion to funded time. IPv6,
custom domains, TCP ports and joining a tailnet are included. See pricing.
Updated
On this page
Private preview links#
A preview link shares an HTTP port without adding a custom domain. Keep it private when only selected visitors should connect. Anyone holding its token can use it until it expires or you rotate it. Revoking the key or sandbox session that was handed a token, or removing the member whose key it was, also refuses every token for that preview immediately, as a rotate does. Read the preview again to get a new one.
A page on another preview cannot open a private preview with the visitor's
cookie. A link, image, frame or request from another sandbox's preview is
refused with origin_not_allowed. These are allowed: another port of the same
sandbox, a site listed in embedOrigins, and a request that sends the token itself.
The API can issue a token with a relative ttlSeconds lifetime, or an absolute
expiresAt ISO timestamp. If you set both, the earlier deadline wins. Tokens
issued through a sandbox session also end when that session ends. An absolute
deadline is never rounded past the time you requested. Read the returned
tokenExpiresAt for the actual end time. Each create and read signs its own
token. So a list shows a different token and tokenExpiresAt than the create
did. The token you already hold keeps working until its own expiry or a rotate.
See private preview tokens for the supported API and MCP inputs.
Search engines
No preview address is ever indexed by a search engine, private or public. Every
response under runtimehost.com carries X-Robots-Tag: noindex, nofollow, in
place of any header your server sends. /robots.txt on a preview address is
Runtime's own file, not your server's. For a site that should appear in search
results, add a custom domain. It serves your site's own
headers and robots.txt unchanged.
Custom domains#
Terminalruntime domains add app.example.com <sandbox> 3000The response lists two DNS records to set at your DNS provider:
| Record | Name | Value | What it does |
|---|---|---|---|
| TXT | _runtime-challenge.app.example.com |
runtime-verify=<token> |
Proves the name is yours |
| CNAME | app.example.com |
domains.runtimehost.com |
Sends visitors to Runtime |
A name with no subdomain (example.com, example.co.uk) cannot have a CNAME.
The response gives an A record (and an AAAA for an IPv6 address) with the
addresses to set instead. Then check:
Terminalruntime domains verify app.example.comThe name goes live when the TXT record matches. The first visit gets its
certificate from Let's Encrypt, which takes a few seconds. As for a
preview link, your server must listen on 0.0.0.0 or
localhost inside the sandbox. Start it with spawn, which keeps it running.
WebSockets work. A paused sandbox is woken by a visit, as with a preview.
- Ownership is the TXT record. A CNAME pointing at Runtime proves nothing. So a record someone forgot to delete cannot be used to take their name. Each claim has its own token.
- The DNS owner wins. If another account proves the same name later, it takes the name over and your claim ends.
- To move the name to another sandbox or port, run
addagain with the new target. To stop serving it, runruntime domains rm app.example.com. Either change takes effect before the command returns. This includes open connections. - Limits: 50 domains per account, 20 added a day. Certificates are issued for proved names only.
- Reports reach Runtime through the
X-Runtime-Reportheader on every response and the abuse address, as for previews. Runtime can take a name down. It stops serving immediately and stays down for every account.
On an App
An App serves your domain through every deploy and rollback, with no certificate fetched on a visit. The name's certificate is issued, checked and served before you point the name at the App.
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const domain = await runtime.domains.add({ hostname: "example.com", appId: "<your App's id>" });console.log(domain.records);The response lists three records:
| Record | Name | Value | What it does |
|---|---|---|---|
| TXT | _runtime-challenge.example.com |
runtime-verify=<token> |
Proves the name is yours |
| CNAME | _acme-challenge.example.com |
<token>.<challenge zone> |
Lets Runtime get and renew its certificate |
| A | example.com |
the App's addresses | Sends visitors to the App |
Set the TXT and the _acme-challenge CNAME first, then verify. Within a few
minutes, certificate.ready turns true on runtime.domains.get. Every Runtime
router now serves a checked certificate for the name. Only then change the A
record (or the CNAME, for a subdomain such as www). That way the first visitor
already gets HTTPS. Keep the _acme-challenge CNAME. Renewals use it, at the
latest 30 days before the certificate expires. If the first certificate
authority fails, renewals use a second one. A deploy or a rollback never changes
DNS or a certificate.
TCP ports#
Terminalruntime ports open <sandbox> 5432# 203.0.113.10:23456psql "postgres://app@203.0.113.10:23456/app?sslmode=require"A TCP port carries raw TCP from a public address and port to a port inside the sandbox. Opening the same sandbox and port again returns the same public port.
- Anyone who knows the address can connect. Use your service's own authentication and TLS.
- Only TCP is carried. UDP (most game servers' own traffic, QUIC) is not.
- Limits: 5 ports per sandbox, 20 per account and 50 opened a day. Each sandbox's ports share 256 open connections, 50 new connections a second, and 32 connections from any one client address.
- Speed: each sandbox's ports move up to 500 Mbit/s each way, 200 Mbit/s once 10 GiB has moved at that speed, and 500 GiB a day, in and out together. When ports of several sandboxes on one address send at the same time, they share 500 Mbit/s equally.
- Use the address each port was given. A port keeps its address for as
long as it is open. Ports opened later may get a different address from
earlier ones. So read it from the response (
address,connect). Do not assume one address for all of them. - A closed port is held for a day before anyone else can be given it. So a client still pointed at it never reaches someone else's service.
- A paused sandbox is woken by a connection. Its connections end when it stops, pauses or is deleted.
- Data the sandbox sends back is outbound traffic. It counts against the account's monthly allowance, then costs $0.02 per GB. This applies to sandboxes created from 5 October 2026. Data that clients send in is free. See pricing.
runtime ports lslists them.runtime ports close <portId>closes one, and its open connections, before it returns. A port you open listens before its address is returned.
Dedicated outbound addresses#
Terminalruntime ips reserve# 203.0.113.50From then on, every sandbox in your account sends from that address, on every port. A database, an API provider or a firewall can then admit exactly you. No other account sends from it while you hold it.
- One IPv4 address per account, and one IPv6 with
--ipv6. - Your sandboxes send only in the address families you hold. With an IPv4
address and no IPv6 one, a site that has no IPv4 address is refused with
address-family. It is never reached from a shared IPv6 address. - If funding expires, traffic stops while the reservation remains yours. There is no fallback to a shared address and no charge for the unpaid interval.
fundedreports whether traffic is funded now.fundedUntilis the last funded-through time and may be in the past; it is null when no meter exists.rateMicrosandrateUnitgive the price of the reserved address. If renewal is blocked, check your balance, spending limits and permissions.runtime ips release <addressId>gives it back. Your sandboxes send from the shared addresses again immediately. A released address is held for 30 days before any other account can get it. Remove it from your allow-lists first.- Addresses are added to Runtime as accounts need them. If none is free, the
response is
no_capacity(503). Write to support. - An address is served from one place. So while you hold one, your sandboxes
start only where it can send from. If there is no room there now, a create
returns
no_capacity(503). It does not start a sandbox that would send from a shared address, so retry with the same idempotency key.
Your own upstream proxy#
Your sandboxes' outbound connections can go through your own HTTP or HTTPS proxy, after Runtime's own rules allow them. Your proxy's controls and logs apply.
Terminalruntime networks upstream-proxy set http://proxy.example.com:3128 --host '*.internal.example.com'runtime networks upstream-proxy getruntime networks upstream-proxy removeIt is a setting of the whole account, so it lives under runtime networks, as
runtime.networks.upstreamProxy in JavaScript, runtime.networks.upstream_proxy
in Python and runtime_networks_upstream_proxy_* in MCP. The CLI and SDK
commands were added in withruntime 0.7.0. The API and MCP tools work now.
Without --host, every connection goes through it. If the proxy fails, the
connection fails. Nothing goes around it.
Security has the rest: proxy credentials, what your
proxy sees, and UDP.
Reach your other sandboxes by name#
Turn it on once, and every sandbox in your account can reach the others by name,
over TCP, on any port. For example, a database in one, a queue in another, and
the agent in a third. It is free, and it stays off until you turn it on. The
setting is named networks private in the CLI, the API (/v1/networks/private)
and MCP (runtime_networks_private). It has nothing to do with the WireGuard
private network below, which joins your own network to
your sandboxes.
Terminalruntime networks private on# then, in any of your sandboxes:psql -h db.sandbox.internal -p 5432 appcurl http://web.sandbox.internal:3000/runtime networks private statusruntime networks private off # open connections are cut within seconds- The name is the one the sandbox was created with (
name), at<name>.sandbox.internal, or its id, at<id>.sandbox.internal. Names are matched without regard to case. Use letters, digits, dots and dashes. If two of your sandboxes have the same name, use the id. - Only your own sandboxes respond. Another account's sandbox of the same
name is never reached. Nor is anything outside Runtime.
.sandbox.internalnames are never looked up in public DNS. - Any port the target listens on, except Runtime's own relays inside every sandbox (10800, 10802 and 10853). TCP only.
- A paused sandbox wakes when a connection reaches it. A stopped one does not: start it first.
- A new sandbox is reachable immediately after it starts running, and can connect to your other sandboxes right away.
- Open connections: up to 256 to any one sandbox and 1,024 across your
account. Past either, new connections return
429withX-Runtime-Egress: private-network-rate-limited. - New connections: up to 1,000 a second across your account (2,000 at
once) and 500 a second to any one sandbox (1,000 at once). A connection
over the rate waits for its turn. It returns
429withX-Runtime-Egress: private-network-rate-limitedonly when its turn is more than 10 seconds away. - A kept top-up, for paid sandboxes. An account without one is told so when
it turns this on. A connection from a sandbox without credit is refused with
private-network-paid-only. A sandbox with its internet off also reaches no other sandbox. - Not billed, and not limited as uploads: these connections are not
outbound traffic. They do not count toward a sandbox's upload speed limits or
its daily network amount. Each sandbox can be reached for 100 GiB a day, in
and out together, from all of its callers. Past that, new connections to it
return
429withX-Runtime-Egress: private-network-quota-exhausteduntil the window ends.
From the SDKs, runtime.networks.private (MCP: runtime_networks_private_*):
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();await runtime.networks.private.set({ enabled: true });const { enabled } = await runtime.networks.private.get();Pythonruntime.networks.private.set(enabled=True)A program that connects directly sees a refused connection close immediately.
Through the proxy (curl -x "$HTTP_PROXY", or any program that reads
HTTP_PROXY), the response's X-Runtime-Egress header says why:
private-network-off (turn it on), private-network-no-sandbox (no sandbox of
yours has that name or id), private-network-not-running (it is stopped, or
could not be woken), or private-network-port-reserved.
Signed requests#
Turn it on once, and your sandboxes can sign their HTTP requests as Runtime's
agents with Web Bot Auth. Sites behind Cloudflare and other bot managers can
then tell them from anonymous traffic (Outbound traffic).
It is free. It needs credit on the account, and it is off until you turn it on.
The setting is networks signed-requests in the CLI, the API
(/v1/networks/signed-requests) and MCP (runtime_networks_signed_requests_*).
Terminalruntime networks signed-requests on# then, in any of your sandboxes, the headers for one site:TOKEN=$(curl -s -H "Authorization: Bearer $RUNTIME_ID_TOKEN_REQUEST_TOKEN" \ "$RUNTIME_ID_TOKEN_REQUEST_URL?audience=https://withruntime.com/signed-requests" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')curl -s -H "x-runtime-sandbox-identity: $TOKEN" \ "https://withruntime.com/api/signed-requests/sign?authority=example.com"# {"authority":"example.com","headers":{"Signature-Agent":...,"Signature-Input":...,"Signature":...},"expiresAt":"..."}runtime networks signed-requests off # new signatures stop at once- Send the three headers with every request to that site until
expiresAt. That is 5 minutes after the response. Then request them again.authorityis the site's host, with its port when it is not 80 or 443. - Refusals say why:
signed_requests_off(403: turn it on, or the sandbox is not running),invalid_identity(401: a token for another audience),rate_limited(429: more than 120 a minute from one sandbox).
From the SDKs, runtime.networks.signedRequests (Python:
runtime.networks.signed_requests):
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();await runtime.networks.signedRequests.set({ enabled: true });const { enabled, allowed } = await runtime.networks.signedRequests.get();Private networks#
A WireGuard tunnel connects a machine or router on your network to your
sandboxes (runtime networks wireguard, runtime.networks.wireguard,
runtime_networks_wireguard). Sandboxes that reach each other need no tunnel.
See reach your other sandboxes by name.
Every sandbox gets an address in the tunnel's subnet. Your machines reach any
port on it there. While you have a tunnel, your sandboxes start where its
gateway can reach them both ways. If there is no room there now, a create
returns no_capacity (503). A retry with the same idempotency key goes ahead
once there is room.
Terminalruntime networks wireguard create # subnet 10.250.0.0/16 by defaultruntime networks wireguard peer add office --route 10.0.0.0/16 # writes runtime.confsudo wg-quick up ./runtime.confruntime networks wireguard get # each sandbox's addresspsql -h 10.250.0.32 appruntime networks wireguard peer add makes the WireGuard key pair on your
machine. The private key goes only into runtime.conf, which only you can read.
Any WireGuard client works with a complete file, such as wg-quick on Linux and
macOS, the WireGuard apps on Windows, macOS, iOS and Android, or your router. To
use a key you already have, pass --public-key. If the gateway key is not ready,
configReady is false and the file is a draft. Keep its private key, add the
server key when it is available, and follow the CLI instructions before you
bring it up. Do not generate another peer just to get the same private key back.
- Choose a subnet your network does not use, from
/16to/24, when you create the tunnel:--subnet 172.30.0.0/16. --routenames your own ranges behind the peer. List them when your sandboxes will reach your network through the peer. TCP connections work in both directions. UDP is not carried by this tunnel.- What a peer reaches: your own account's sandboxes, at their tunnel addresses, on any port except Runtime's own relays inside the sandbox (10800, 10802 and 10853). Nothing else: not another account's sandboxes, not Runtime's servers, not the internet. Only TCP is carried.
- Rotate a key with
runtime networks wireguard peer rotate <peerId>. The old key stops working within seconds, and connections opened with it end.runtime networks wireguard peer rm <peerId>removes a peer the same way. - Funding: an unfunded tunnel stops traffic but keeps its reservation and
peers until you delete it. Unpaid time is never billed.
funded,fundedUntil,rateMicrosandrateUnitreport its current funding and quote. - Limits: one tunnel per account, 16 peers. A paused sandbox is woken by a connection.
From the SDKs:
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();await runtime.networks.wireguard.create();const office = await runtime.networks.wireguard.addPeer({ name: "office", routes: ["10.0.0.0/16"],});// office.config is the wg-quick file; with no publicKey given, it holds the// private key Runtime generated for you, shown once.Pythonruntime.networks.wireguard.create()office = runtime.networks.wireguard.add_peer("office", routes=["10.0.0.0/16"])Your Tailscale network#
A paid sandbox can join your own tailnet. Your machines, databases and services on Tailscale can then reach it, and it can reach them, by their tailnet addresses. Make an auth key in Tailscale's admin console. Make it ephemeral and tagged, and one-use unless the sandbox may pause for long (see below). Store it for jobs. This is the copy a sandbox can be given. Then join:
Terminalprintf %s "$TS_AUTHKEY" | runtime secrets set TS_AUTHKEY --jobsruntime sandboxes tailscale up <sandbox> --auth-key-secret TS_AUTHKEY --tag tag:agentsruntime sandboxes tailscale status <sandbox> # its 100.x address and nameruntime sandboxes tailscale down <sandbox> # removed from the tailnet at once--hostname names the machine. The default is the sandbox's name. At create,
tailscale: { authKeySecret: "TS_AUTHKEY", tags: ["tag:agents"] } joins as
soon as it runs. A join that fails stops the new sandbox and says why.
- The key stays in memory. It is read from your secret with your key's
right to reveal it, and given to
tailscaledin the sandbox on a memory-only mount. It is never in a response, a log, the sandbox's disk or a command line. Code running as root in the sandbox could read it, as on any machine running Tailscale. That is why an ephemeral, tagged key is the one to use. - An ephemeral machine.
downlogs it out and takes it off the tailnet immediately. A stop or a delete does not log it out first. The machine goes offline with the sandbox, and Tailscale removes an ephemeral machine by itself once it has been offline a while, up to about an hour. To have it removed immediately, rundownbefore the stop, or remove it in Tailscale's admin console. - Pausing. A paused sandbox is offline on the tailnet and back when it wakes. If Tailscale removed it meanwhile, the sandbox logs in again by itself with a reusable key. A one-use key cannot do that, so join again.
- How it reaches the tailnet. Where the sandbox's kernel allows a TUN
device, which is the default image, it has a
tailscale0interface. It reaches 100.x addresses directly (mode: "kernel"). Otherwise (mode: "userspace"), your tailnet still reaches the sandbox's ports. Programs in the sandbox reach the tailnet through the SOCKS5 and HTTP proxy onlocalhost:1055. - MagicDNS. Once an image with Runtime's Tailscale DNS bridge has been
qualified and released, it can resolve
.ts.netnames in both kernel and userspace modes. Ordinary public names keep using the usual resolver. Older images stay connected. They warn that MagicDNS needs an image with the bridge. Use the 100.x address, or rebuild the image, before you rely on tailnet names. - Your network rules still apply. Tailscale's traffic leaves through the
same proxy as everything else. So the sandbox needs
*.tailscale.comandpkgs.tailscale.comon port 443. Paid sandboxes reach those by default. The sandbox also needs UDP for direct connections. Without it, traffic falls back to Tailscale's relays. Tailscale encrypts what travels inside the tailnet. Your secrets are never put into it. Your tailnet's access rules decide what the sandbox's tags reach. - Not copied from memory. While a sandbox is on a tailnet, a fork or a
memory snapshot of it is refused with
tailscale_joined. The copy would be the same Tailscale machine and would hold the key. Take the sandbox off the tailnet, copy it, and join each copy. A disk snapshot is taken as usual. - Sandboxes without credit cannot join (
payment_required). The first join downloads Tailscale into the sandbox, which adds a few seconds. Later joins reuse it.
Outbound UDP#
Paid sandboxes send UDP to any public address and port: HTTP/3 and QUIC, DNS to a resolver of your choice, time sync, game and media servers, WireGuard or other VPN clients. Sandboxes without credit send TCP only.
- Each flow has one destination. Only that destination's replies come back. Nothing reaches a sandbox over UDP unless the sandbox asked for it.
- Private, link-local and cloud metadata addresses are refused, and so are the ports no sandbox reaches over TCP either. The sandbox's network rules apply.
- Datagrams are limited to 1,472 bytes. A sandbox sends and receives at most 50,000 datagrams a second. That is enough for full-size traffic at its 500 Mbit/s upload peak, within its upload limits and daily allowance.
- A flow that has sent 256 datagrams without a reply is closed, so a sandbox cannot flood a host that does not reply.
- NTP to port 123 accepts standard 48-byte client requests and matching server replies only.
- There is no public inbound UDP port. UDP is not carried through a private tunnel or through your own upstream proxy.
Errors#
| Code | Status | Meaning |
|---|---|---|
payment_required |
402 | The account has no kept top-up, or the sandbox runs without credit |
tailscale_failed |
422 | Tailscale refused the login, or the sandbox could not reach it |
tailscale_joined |
409 | A fork or memory snapshot of a sandbox on a tailnet |
network_not_allowed |
403 | Runtime turned network features off for the account |
quota_exceeded |
409 | A limit above was reached: close or delete one first |
rate_limited |
429 | Too many added today |
no_capacity |
503 | No public port or dedicated address is free now |
network_unavailable |
501 | The feature is not switched on in this region yet |
What a visitor can get
When Runtime's router responds to a visit to an App, a balancer or a custom
domain itself, rather than your code, the response names its code in the
x-runtime-error header and the body, with the request's id:
| Code | Status | Meaning |
|---|---|---|
bad_request |
400 | No hostname, two Host headers, or a Host that disagrees with the authority |
firewall_denied |
403 | The firewall refuses the visitor |
no_route |
404 | Nothing is served at this hostname |
misdirected_request |
421 | TLS was opened for a name whose certificate does not cover this one |
rate_limited |
429 | The visitor is over the per-address limit (Retry-After) |
unsupported_protocol |
501 | CONNECT did not name the websocket protocol |
upstream_failed |
502 | Every copy tried failed before responding |
machine_no_capacity |
503 | No copy had a free slot in time (Retry-After) |
router_not_ready |
503 | A router just started has not yet had its route table (Retry-After: 1) |
network_off |
503 | Runtime cut the machine's network |
router_not_ready comes only from a router that has never had its table, such
as a new one. It cannot yet say whether the name is served. So it asks the
visitor to try again in a second, rather than return no_route. Every router
keeps its table on disk, so a restart serves immediately.
Try it on your own work. Sign up, then paste the setup prompt into your coding agent.