# 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](./pricing#how-many-at-once)). 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:port` for 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 ssh` and `port-forward` are a separate connection from your own computer ([SSH and editors](./editors)). - **Sandboxes by name:** your sandboxes reach each other at `.sandbox.internal`, free. It needs no tunnel. The CLI and MCP call it `networks 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](./pricing). ## Private preview links A [preview link](./sandbox-environment) 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](./api#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](#custom-domains). It serves your site's own headers and `robots.txt` unchanged. ## Custom domains ```bash no-run runtime domains add app.example.com 3000 ``` The 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=` | 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: ```bash no-run runtime domains verify app.example.com ``` The 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](./sandbox-environment), 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 `add` again with the new target. To stop serving it, run `runtime 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-Report` header 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. ```ts no-run import { Runtime } from "withruntime"; const runtime = new Runtime(); const domain = await runtime.domains.add({ hostname: "example.com", appId: "" }); console.log(domain.records); ``` The response lists three records: | Record | Name | Value | What it does | | ------ | -------------------------------- | -------------------------- | ------------------------------------------ | | TXT | `_runtime-challenge.example.com` | `runtime-verify=` | Proves the name is yours | | CNAME | `_acme-challenge.example.com` | `.` | 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 ```bash no-run runtime ports open 5432 # 203.0.113.10:23456 psql "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](./pricing#network-products). - `runtime ports ls` lists them. `runtime ports close ` closes one, and its open connections, before it returns. A port you open listens before its address is returned. ## Dedicated outbound addresses ```bash no-run runtime ips reserve # 203.0.113.50 ``` From 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. - `funded` reports whether traffic is funded now. `fundedUntil` is the last funded-through time and may be in the past; it is null when no meter exists. `rateMicros` and `rateUnit` give the price of the reserved address. If renewal is blocked, check your balance, spending limits and permissions. - `runtime ips release ` 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. ```bash no-run runtime networks upstream-proxy set http://proxy.example.com:3128 --host '*.internal.example.com' runtime networks upstream-proxy get runtime networks upstream-proxy remove ``` It 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](./security#your-own-proxy) 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](#private-networks) below, which joins your own network to your sandboxes. ```bash no-run runtime networks private on # then, in any of your sandboxes: psql -h db.sandbox.internal -p 5432 app curl http://web.sandbox.internal:3000/ runtime networks private status runtime networks private off # open connections are cut within seconds ``` - **The name** is the one the sandbox was created with (`name`), at `.sandbox.internal`, or its id, at `.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.internal` names 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 `429` with `X-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 `429` with `X-Runtime-Egress: private-network-rate-limited` only 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 `429` with `X-Runtime-Egress: private-network-quota-exhausted` until the window ends. From the SDKs, `runtime.networks.private` (MCP: `runtime_networks_private_*`): ```ts check import { Runtime } from "withruntime"; const runtime = new Runtime(); await runtime.networks.private.set({ enabled: true }); const { enabled } = await runtime.networks.private.get(); ``` ```python check runtime.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](./egress#signed-requests)). 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_*`). ```bash no-run runtime 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. `authority` is 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`): ```ts check import { 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](#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. ```bash no-run runtime networks wireguard create # subnet 10.250.0.0/16 by default runtime networks wireguard peer add office --route 10.0.0.0/16 # writes runtime.conf sudo wg-quick up ./runtime.conf runtime networks wireguard get # each sandbox's address psql -h 10.250.0.32 app ``` `runtime 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 `/16` to `/24`, when you create the tunnel: `--subnet 172.30.0.0/16`. - **`--route`** names 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 `. The old key stops working within seconds, and connections opened with it end. `runtime networks wireguard peer rm ` 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`, `rateMicros` and `rateUnit` report its current funding and quote. - **Limits:** one tunnel per account, 16 peers. A paused sandbox is woken by a connection. From the SDKs: ```ts check import { 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. ``` ```python check runtime.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: ```bash no-run printf %s "$TS_AUTHKEY" | runtime secrets set TS_AUTHKEY --jobs runtime sandboxes tailscale up --auth-key-secret TS_AUTHKEY --tag tag:agents runtime sandboxes tailscale status # its 100.x address and name runtime sandboxes tailscale down # 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 `tailscaled` in 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.** `down` logs 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, run `down` before 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 `tailscale0` interface. 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 on `localhost:1055`. - **MagicDNS.** Once an image with Runtime's Tailscale DNS bridge has been qualified and released, it can resolve `.ts.net` names 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.com` and `pkgs.tailscale.com` on 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](./security#secrets-sandboxes-never-see) 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.