Skip to content
Markdown

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: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).
  • Sandboxes by name: your sandboxes reach each other at <name>.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.

Updated

On this page

Custom domains#

Terminalruntime domains add app.example.com <sandbox> 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=<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.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, 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.

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 ls lists 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.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 <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 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 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.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_*):

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. 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):

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 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 <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, 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:

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 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 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.

Start free, no card

Was this page right?