Sandboxes
Sandboxes are isolated Linux VMs you create on demand, run commands in, and destroy. Use them for coding agents, dependency installs, test runs, builds, and isolated code execution in development or production.
Each sandbox is scoped to a Railway environment, so it can join the same private network as your services and databases. Sandboxes are available on every plan.
Follow the sandbox quickstart to create your first sandbox.
How it works
Each sandbox is a programmatically controllable, fully isolated virtual machine. You create one, run commands against it with exec, and destroy it when you're done. The standard sandbox image uses an Ubuntu base with git, Node, and common coding agents preinstalled. Sandboxes are ready to exec against once Sandbox.create() resolves.
A typical loop looks like this:
create -> configure -> checkpoint -> create/fork -> verify -> destroyPrepare an environment once, checkpoint it, then boot or fork as many copies as you need. Each attempt is isolated, and you throw away the ones you don't keep.
You can drive sandboxes three ways:
- The TypeScript SDK, for building sandboxes into your own product or agent.
- The CLI, for your terminal and for local coding agents.
- The dashboard, for browsing, SSH, and cleanup.
Dashboard
Navigate to the Sandboxes tab in your project to view, create, and destroy sandboxes. A sandbox's detail page shows its public domains, with a link and target port for each published route.
You can SSH into a running sandbox directly without leaving the dashboard. If you have SSH keys configured in your Railway account, you can also copy the SSH command from the dashboard to use in your terminal of choice.
To add SSH keys to your account, go to Account Settings -> SSH Keys.
TypeScript SDK
The SDK is the primary interface for working with sandboxes programmatically. It's open source on GitHub. It requires Node.js 22 or later.
Installation
npm install railwayOr scaffold a new project with the SDK preconfigured:
npm create railway@latestbun add railway and bun create railway@latest work too.
Quick start
import { Sandbox } from "railway";
// reads RAILWAY_API_TOKEN + RAILWAY_ENVIRONMENT_ID from the environment
const sandbox = await Sandbox.create();
const { stdout } = await sandbox.exec("echo hello");
console.log(stdout);
await sandbox.destroy();Sandbox.create() resolves once the sandbox is RUNNING and ready to accept commands.
Creating a sandbox
These static methods cover every way to get a Sandbox:
// Provision a new sandbox
const sandbox = await Sandbox.create();
// Provision from a template (see Templates below)
const sandbox = await Sandbox.create(template);
// Fork a running sandbox (see Forking below)
const sandbox = await Sandbox.create(source);
// Reattach to an existing sandbox by id
const sandbox = await Sandbox.connect(sandbox.id);
// throws `SandboxNotFoundError` if the sandbox does not exist in the environment.
// List all sandboxes in the environment
const sandboxes = await Sandbox.list();Running commands
exec starts a command and returns a handle. Await the handle to get the final result. exec doesn't throw on a non-zero exit code, so inspect exitCode instead.
const result = await sandbox.exec("npm run build", { timeoutSec: 120 });
result.exitCode; // number | null (null when a signal ended the command)
result.stdout; // string
result.stderr; // string
result.truncated; // true if the output was truncated
result.timedOut; // true if the command hit timeoutSecPass timeoutSec to stop the command after a deadline and resolve with timedOut: true. Without it, the command runs until it exits, so you can run long-lived processes like agents, dev servers, and builds. See Long-running commands to stream their output and detach or reattach the session, and Cancelling a command for how termination works.
Set cwd to run the command in a directory, and env to layer extra environment variables over the sandbox's own:
const result = await sandbox.exec("pnpm test", {
cwd: "/app",
env: { CI: "true" },
});Both options are applied to the command directly rather than wrapped into the command string. The command fails if cwd doesn't exist. Per-command env values take precedence over the sandbox's own variables and last for that command only. For variables every command needs, set env when you create the sandbox instead. Both options apply to fresh commands only, and are rejected when you reattach to a session by name.
Long-running commands
A command started with exec runs on the sandbox independently of the client that started it. The handle exposes a durable sessionName, so you can stop streaming a command and reattach to it later, even from a different process. This suits agents, dev servers, and any job that outlives a single connection.
Stream output as it arrives with the onStdout and onStderr callbacks:
const handle = sandbox.exec("npm run agent", {
onStdout: (chunk) => process.stdout.write(chunk),
onStderr: (chunk) => process.stderr.write(chunk),
});
const result = await handle; // resolves when the command exitsTo stop following the output without ending the command, call detach(). It resolves with the session name. The command keeps running, and you reattach later by passing that name back to exec.
const handle = sandbox.exec("npm run agent");
const sessionName = await handle.sessionName;
await handle.detach(); // stop streaming; the command keeps running
// Later, from any process with access to the environment:
const reattached = await Sandbox.connect(sandbox.id);
const result = await reattached.exec(
{ sessionName },
{ onStdout: (chunk) => process.stdout.write(chunk) },
);Reattaching replays the output retained for the session, then follows it live. Pass resumeFromLastRead: true to receive only the output produced since the server's last read, instead of replaying from the start.
Call handle.kill(signal) to terminate a running command. It sends the signal (TERM by default) to the command's process group, and the handle then resolves with the command's exit. Detaching leaves the command running. Killing ends it.
To find sessions you can reattach to, call sandbox.sessions(). It lists the sandbox's shell and exec sessions, running or recently exited, and resolves to null when the sandbox can't report them:
const sessions = await sandbox.sessions();
const running = sessions?.filter((s) => s.kind === "EXEC" && s.running);Each entry includes name (pass it as sessionName to reattach), kind (SHELL or EXEC), running, exitCode, exitedAt, attached (whether a client is connected right now), and command.
Writing to stdin
By default, a command's stdin is closed as soon as it starts. Pass stdin: true to keep it open and write to it through handle.stdin. Use this to drive interactive programs, or agent CLIs that speak JSON-RPC over stdio.
const handle = sandbox.exec("my-agent --stdio", {
stdin: true,
onStdout: (chunk) => process.stdout.write(chunk),
});
const request = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" });
await handle.stdin.write(request + "\n");
await handle.stdin.end(); // send EOF
const result = await handle;write accepts a string or a Uint8Array. Writes are flow-controlled, so a large write waits until the sandbox is ready for more data instead of buffering without limit. Await each write before sending the next, and call end() to signal the end of input. Cancellation still works while a write is waiting, so a command that stops reading its input doesn't block your program.
Cancelling a command
Three controls end a running command: timeoutSec, an AbortSignal passed as signal, and handle.kill(). The first two take the same path. They send TERM to the command's process group, escalate to KILL if it hasn't exited after five seconds, and settle only once the sandbox confirms the exit.
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("deadline reached")), 60_000);
try {
const result = await sandbox.exec("npm run e2e", {
signal: controller.signal,
onStdout: (chunk) => process.stdout.write(chunk),
});
console.log(result.exitCode);
} catch (error) {
if (error === controller.signal.reason) {
// the abort ended the command
}
}The two differ in how they settle. A timeout always resolves with timedOut: true, so you can read the output captured before the deadline: exitCode is -1 once the sandbox confirms the command stopped, or null if it doesn't confirm within 10 seconds. The deadline starts once the command has been sent. An abort rejects with the signal's reason. Aborting before the command starts cancels setup, and no command runs.
A command's outcome is unknown in two cases. If an abort's termination isn't confirmed within 10 seconds, the handle rejects with RailwayConnectionError. If the connection drops before the command reports an exit, it rejects with ExecInterruptedError, which carries the stdout and stderr captured so far. In both cases the command may still be running, or the sandbox may have been destroyed. For a durable command, reattach by session name or call sessions() to find out.
Capturing output
An exec result captures all of stdout and stderr by default. To bound memory, set maxOutputBytes: each stream keeps up to that many bytes and the result sets truncated: true when one was cut. The onStdout and onStderr callbacks always receive the full stream, including output beyond the cap.
For long-lived commands you only stream, pass captureOutput: false. Callbacks still run, but the result's stdout and stderr come back empty, so memory stays flat no matter how much the command prints.
const result = await sandbox.exec("npm run dev", {
captureOutput: false,
onStdout: (chunk) => process.stdout.write(chunk),
});Ephemeral commands
Every exec creates a durable session by default, which is what lets you detach and reattach. For short commands that don't need one, pass ephemeral: true. An ephemeral command has no session: sessionName and detach() reject, and if the connection drops, its processes are cleaned up instead of left running.
const status = await sandbox.exec("git status --short", { ephemeral: true });Ephemeral commands still support streaming, stdin, and cancellation. The option applies to fresh commands only.
A durable session keeps about the last 8 MiB of output so you can reattach. If a durable command writes more than that faster than your program reads it, output from the middle of the stream can be lost without truncated being set. For commands with large output, such as builds, test runs, or data dumps, use ephemeral: true, or have the command write to a file and read the file back.
Note: Writable stdin and ephemeral commands depend on process controls in the sandbox host. If the sandbox you're connected to doesn't support them, a plain exec still works, but stdin: true and ephemeral: true reject with ExecControlUnsupportedError and no command runs. This is rare and limited to older sandbox hosts.
Running a command without a WebSocket
exec streams over a WebSocket. Where your runtime can't open one, execHttp sends the command in a single HTTPS request and returns exitCode, stdout, stderr, truncated, and timedOut:
const { exitCode, stdout } = await sandbox.execHttp("node --version", {
timeoutSec: 30,
});execHttp has no streaming, stdin, or cancellation. Each stream is cut at 16,000 bytes, which sets truncated: true. Its timeoutSec is a whole number of seconds, enforced by the server: 2 minutes by default, 10 at most. A timed-out command reports timedOut: true and exitCode: -1. Use exec everywhere else.
Files
sandbox.files reads and writes files on a running sandbox's filesystem.
| Method | Description |
|---|---|
read | Read a file as text, bytes, or a stream |
write | Create or overwrite a file, creating parent directories |
list | List a directory's entries |
stat | Get metadata for a path |
exists | Check whether a path exists |
mkdir | Create a directory, including parents |
rename | Move or rename a file or directory |
remove | Delete a file or empty directory |
Reading and writing
Write a file and read it back:
await sandbox.files.write(
"/app/config.json",
JSON.stringify({ port: 8080 }),
);
const text = await sandbox.files.read("/app/config.json"); // string
const bytes = await sandbox.files.read("/data/model.bin", {
format: "bytes",
}); // Uint8Arraywrite creates the file and creates missing parent directories automatically.
Files are created with mode 0644 by default. Pass mode to set permissions, for example to make a script executable:
await sandbox.files.write("/app/run.sh", "#!/bin/sh\necho hello\n", {
mode: 0o755,
});write accepts several content types, which differ in how they recover from a dropped connection:
write input | How it uploads |
|---|---|
string, Uint8Array, ArrayBuffer, Blob | Buffered, retried automatically on a dropped connection |
ReadableStream, AsyncIterable<Uint8Array> | Streamed without buffering, one-shot (not retried) |
() => stream (a factory) | Streamed without buffering, retried by calling the factory again |
Streaming lets you push a file larger than memory. A bare stream isn't retried, so an error mid-transfer fails with RailwayConnectionError and can leave a partial file.
Pass a factory so a retry reads a fresh stream:
import { createReadStream } from "node:fs";
await sandbox.files.write(
"/data/dataset.bin",
() => createReadStream("./dataset.bin"),
);Read a file as a stream to pull large files without filling memory, or read a byte range. Use offset and length to read from the start, or fromEnd with length to tail the end of a file:
const stream = await sandbox.files.read("/data/out.bin", {
format: "stream",
});
for await (const chunk of stream) process.stdout.write(chunk);
const tail = await sandbox.files.read("/var/log/app.log", {
length: 4096,
fromEnd: true,
});Listing
List a directory and read each entry's metadata:
for (const entry of await sandbox.files.list("/app")) {
console.log(entry.name, entry.size, entry.isDir, entry.modTime);
}Removing
remove deletes a single file or empty directory. For recursive deletes, run sandbox.exec("rm -rf ...").
Reading a missing path throws SandboxFileNotFoundError. Other failures throw SandboxFilesError, which reports the failing operation and path.
Destroying a sandbox
Use destroy() for explicit teardown, or await using to destroy automatically when the scope exits, even on throw.
// Explicit
await sandbox.destroy();
// Automatic via await using (requires Node.js 22+)
await using sandbox = await Sandbox.create();
await sandbox.exec("pytest");
// destroyed automatically hereReconnecting
A sandbox outlives the process that created it. Reattaching by id is useful in serverless and multi-step workflows.
const sandbox = await Sandbox.connect(idString);
await sandbox.exec("cat /tmp/state.json");Call sandbox.refresh() to re-read the sandbox and update its status and other fields in place.
Call sandbox.heartbeat() to reset the sandbox's idle timer without running a command. It also refreshes the handle's fields. Schedule it at an interval shorter than idleTimeoutMinutes while your application needs the sandbox but isn't running commands in it, for example while an agent waits on a model or a user.
const keepAlive = setInterval(() => {
sandbox.heartbeat().catch(console.error);
}, 5 * 60_000);
// When the sandbox is no longer needed:
clearInterval(keepAlive);
await sandbox.destroy();A heartbeat doesn't change the configured timeout, and it throws SandboxNotFoundError once the sandbox has been destroyed.
Templates
A template is a reusable base: an ordered list of build steps that Railway builds once, content-addresses, and caches. Creating a sandbox from a template forks that cached build instead of starting from scratch.
This lets you start a sandbox from a pre-built base, saving spin-up time.
const base = Sandbox.template()
.withPackages("ffmpeg", "git")
.withEnv({ NODE_ENV: "production" })
.workdir("/app")
.run("npm install");
const sandbox = await Sandbox.create(base);
await sandbox.exec("ffmpeg -version");SandboxTemplate is immutable. Every method returns a new template, so a base can branch into variants without mutation.
The template builder exposes these methods:
| Method | Effect |
|---|---|
.run(command) | Add a raw build step |
.withPackages(...names) | Install system packages via apt-get |
.withEnv({ KEY: "value" }) | Set build-time environment variables for subsequent steps |
.workdir(dir) | Set the working directory for subsequent steps |
.build(options?) | Build and cache the template ahead of time |
.withEnv values apply to the build steps only, and aren't baked into sandboxes created from the template. For variables a command needs at runtime, pass env when you create the sandbox. Build-time values are part of the template's content hash, so changing one forces a rebuild.
Sandbox.create(template) builds the template automatically. Call .build() explicitly only to pre-warm the cache before the first create.
Forking
Forking clones a running sandbox's filesystem into a new, independent sandbox. The fork boots fresh from a copy of the source's disk, so files are preserved but running processes and memory are not. Use it to branch a sandbox after expensive setup, for example installing dependencies once and forking per task.
const base = await Sandbox.create();
await base.exec("npm install");
const fork = await base.fork();
await fork.exec("npm test"); // sees the installed dependencies, isolated from baseSandbox.create(source) is equivalent to source.fork(). The source must be RUNNING, and the fork is created in the same environment and region. Requesting a different region returns an error. Pass idleTimeoutMinutes, resources, networkIsolation, domains, or env to configure the fork. It doesn't inherit these settings from the source, including a disabled idle timeout, a custom size, or published domains.
Checkpoints
A checkpoint is a named snapshot of a sandbox's disk, stored server-side in the environment. Capture one from a running sandbox after expensive setup, then boot new sandboxes from it by name, from any process with access to the environment.
const sandbox = await Sandbox.create();
await sandbox.exec("npm install");
await sandbox.checkpoint("after-deps");
await sandbox.destroy();
// Later, from any process with access to the environment:
const fresh = await Sandbox.create("after-deps");
await fresh.exec("npm test"); // sees the installed dependenciescheckpoint(name) requires a running sandbox, which keeps running during the capture. The call resolves once the checkpoint is bootable, so a sandbox with a lot of changed disk takes longer to capture. Reusing a name replaces that checkpoint with the new capture.
Like a fork, a sandbox created from a checkpoint boots fresh from a copy of the disk: files are preserved, running processes are not. Unlike a fork, the source sandbox doesn't need to exist anymore. Use a checkpoint for a base you reuse across sessions, and a fork to branch a live sandbox.
Manage the environment's checkpoints with static methods:
const checkpoints = await Sandbox.checkpoints(); // newest first
await Sandbox.renameCheckpoint("after-deps", "node-base");
await Sandbox.deleteCheckpoint("node-base");Each entry's key field holds the checkpoint name, and renameCheckpoint and deleteCheckpoint take that name. Deleting a checkpoint also deletes its underlying disk snapshot.
The number of checkpoints an environment can hold matches its plan's sandbox limit, listed in Sandbox limits. Checkpoints are counted separately from running sandboxes, and replacing a checkpoint by reusing its name doesn't increase the count.
Configuration
token and environmentId each resolve in order: an explicit option in the configuration object, then an environment variable.
| Option | Environment variable |
|---|---|
token | RAILWAY_API_TOKEN |
environmentId | RAILWAY_ENVIRONMENT_ID |
Pass explicit values to override:
const sandbox = await Sandbox.create({
token: process.env.MY_TOKEN,
environmentId: process.env.MY_ENV_ID,
idleTimeoutMinutes: 30,
resources: { cpu: 2, memoryGB: 4 },
region: "us-east4-eqdc4a",
networkIsolation: "PRIVATE",
domains: [{ port: 3000 }],
env: { NODE_ENV: "production" },
});resources sets the sandbox's size. cpu is in vCPUs and accepts fractions such as 0.5, and memoryGB is in decimal gigabytes. Omitted fields use your workspace's default, and values above your plan's maximum return an error. See Sandbox size for the per-plan defaults and maximums. The option works the same on create, templates, checkpoints, and fork, and a fork doesn't inherit the source's size.
region places a fresh sandbox in one of Railway's regions, using the region identifier listed there, for example us-east4-eqdc4a. Without it, a fresh sandbox runs in US West (us-west2), regardless of your account's preferred region. Forks, checkpoint restores, and templates boot where their data lives: a fork runs in its source sandbox's region, and a sandbox created from a checkpoint or template runs in the region where that snapshot was captured or built. Omit region for those and it's chosen automatically. Requesting a different region returns an error. Read the placement back from sandbox.region. The CLI has no region flag, so use the SDK or the API to choose a region for a fresh sandbox.
idleTimeoutMinutes sets how long a sandbox can sit idle before Railway automatically destroys it. Set it high enough to cover the gaps between steps in reconnect workflows, and low enough to avoid paying for idle compute. Without it, the sandbox uses the plan default. Set it to 0 to disable idle destruction on Hobby and Pro. See Idle timeout for the per-plan defaults and finite ranges.
networkIsolation and domains control whether the sandbox joins the environment's private network and whether it gets a public URL. See Networking.
env bakes environment variables into the sandbox, available to every command over both exec and SSH for the sandbox's lifetime. Use it for values a command needs at runtime, including secrets. Values can reference other Railway variables, for example ${{shared.NPM_TOKEN}}, resolved when the sandbox is created. create and fork both accept env, and a fork doesn't inherit the source's variables.
Examples
For complete, runnable code, see the sandbox examples in the SDK repository.
CLI
The Railway CLI can create, fork, connect to, run commands in, forward ports into, and destroy sandboxes, build templates, capture and boot from checkpoints, and seed variables at create time. See railway sandbox for all subcommands and options.
Limits
Sandboxes per environment
Each environment can run a fixed number of sandboxes at once, based on your workspace's plan. The cap applies per environment, not per project or workspace.
| Plan | Sandboxes per environment |
|---|---|
| Trial | 10 |
| Free | 10 |
| Hobby | 50 |
| Pro | 100 |
Enterprise workspaces start at the Pro cap of 100 sandboxes per environment, and it can be raised on request.
Only sandboxes in the CREATING or RUNNING state count toward the cap. Destroyed sandboxes don't. Creating a sandbox past the cap fails with an error.
Sandbox size
Each sandbox gets a VM size at creation. Without a request, it uses your plan's default. You can ask for a different size, up to your plan's maximum, with the resources option in the SDK or the resources field on the sandboxCreate API mutation (cpu and memoryGB, fractional vCPU allowed). Requests above the maximum return an error. The size is the ceiling a sandbox can use, not what you're billed for. See Pricing.
| Plan | Default | Maximum |
|---|---|---|
| Trial | 2 vCPU / 2 GB | 2 vCPU / 2 GB |
| Free | 2 vCPU / 2 GB | 2 vCPU / 2 GB |
| Hobby | 4 vCPU / 4 GB | 8 vCPU / 8 GB |
| Pro | 8 vCPU / 8 GB | 32 vCPU / 32 GB |
Enterprise maxima are set per agreement.
Timeouts and output
By default, Railway destroys a sandbox after its idle timeout expires. Hobby and Pro workspaces can disable this timeout. Commands have a separate, optional timeout.
| Plan | Default idle timeout | Finite idle timeout range | Disable idle timeout |
|---|---|---|---|
| Hobby and Pro | 30 minutes | 1 to 120 minutes | Set idleTimeoutMinutes: 0 |
| Trial and Free | 5 minutes | 1 to 5 minutes | Not available |
In the CLI, railway sandbox exec streams output live and runs the command until it exits, unless you pass --timeout <SECONDS>, a client-side deadline. When the deadline expires, the command receives SIGTERM and exec exits with code 124. Use --detach to start a command in the background and get a session name back, then --session <name> to reattach later. In the SDK, a command has no timeout unless you set timeoutSec, so long-running commands keep going until they exit. The truncated field on an exec result reports when captured output was cut short by maxOutputBytes. See Capturing output.
Idle timeout
A sandbox's idle timer resets when you run commands or send keepalive heartbeats. Railway also defers idle teardown while an exec command is running, including a detached command, while an SSH shell has foreground work, or when a command finished within the idle timeout window.
The CLI sends keepalive heartbeats while railway sandbox ssh or railway sandbox forward remains connected, even without commands or traffic. In the SDK, call sandbox.heartbeat() to reset the timer yourself. A background process outside an active session doesn't keep the sandbox alive on its own. Once the sandbox has no activity keeping it alive and its idle timeout expires, Railway destroys it.
Set the idle timeout with idleTimeoutMinutes in the SDK or --idle-timeout-minutes in the CLI. On the Hobby and Pro plans it defaults to 30 minutes and can be set from 1 to 120 minutes. On the Trial and Free plans it defaults to 5 minutes and can be set from 1 to 5 minutes. Setting a value above your plan's maximum returns an error.
Disable the idle timeout
On Hobby and Pro, set idleTimeoutMinutes: 0 for an infinite TTL:
const sandbox = await Sandbox.create({ idleTimeoutMinutes: 0 });Any value of 0 or lower disables the timeout. On the Trial and Free plans, creating a sandbox this way fails with an error that names the allowed range.
In the CLI, pass --idle-timeout-minutes 0 to create or fork. This also works with templates and checkpoints. Forks and checkpoint restores don't inherit the source's timeout, so pass 0 again when needed.
The sandbox remains billable until you destroy it with destroy(), await using, or railway sandbox destroy. Exec timeouts remain separate. Omit the idle timeout to use your plan's default.
Networking
Every sandbox has outbound internet access through a NAT gateway. Whether it can reach the rest of your environment over the private network depends on its network isolation mode, set when you create or fork it.
| Mode | Behavior |
|---|---|
ISOLATED | Default. Outbound internet access only. The sandbox can't reach other services in the environment over private networking, and they can't reach it. |
PRIVATE | The sandbox joins the environment's private network and keeps outbound internet access. It can reach other services over private networking, for example postgres.railway.internal, and they can reach it. |
A sandbox is ISOLATED unless you opt into PRIVATE, so existing sandboxes are unaffected.
In the SDK, pass networkIsolation when you create or fork a sandbox, and read it back from sandbox.networkIsolation:
const sandbox = await Sandbox.create({ networkIsolation: "PRIVATE" });
sandbox.networkIsolation; // "ISOLATED" | "PRIVATE"In the CLI, pass --private-network to railway sandbox create or railway sandbox fork.
Neither mode changes how you drive a sandbox. To run commands, use exec or SSH, and to move files in and out, use the files API.
Public domains
Pass domains with networkIsolation: "PRIVATE" when creating a sandbox to publish its HTTP servers on Railway-provided HTTPS domains:
const sandbox = await Sandbox.create({
networkIsolation: "PRIVATE",
domains: [{ port: 8080 }, { prefix: "api", port: 3000 }],
});
console.log(sandbox.domains); // [{ prefix, port, domain }, ...]Start each server on 0.0.0.0 and its requested port. Railway handles HTTPS; publishing a domain doesn't start a server or disable the idle timeout. Destroying the sandbox removes its routes.
You can request up to 10 domains, with unique ports from 1 to 65535. Omitted prefixes are generated from the project name. Explicit prefixes must be unique and contain 1 to 46 lowercase letters, digits, or hyphens, with no leading or trailing hyphen.
Domains are set only at creation, including templates, checkpoints, and forks. Forks don't inherit them; pass domains and networkIsolation: "PRIVATE" again. You can't change domains on an existing sandbox.
sandbox.domains contains { prefix, port, domain } for each published route. Read it through Sandbox.connect(), Sandbox.list(), or sandbox.refresh(), or view the URLs in the dashboard.
In the CLI, use --domain [PREFIX:]PORT with --private-network. In GraphQL, pass SandboxCreateInput.publicDomains and select domains { prefix port domain }. The array is empty until routes are published.
Reaching a port in a sandbox
Use SSH port forwarding to reach a sandbox from your machine without publishing a domain. It works with either network mode.
To reach that server from your machine, forward its port with railway sandbox forward, which maps localhost:3000 to port 3000 in the sandbox:
railway sandbox forward 3000Forwarding runs over SSH, so it needs an SSH key on your Railway account. Add one at Account Settings -> SSH Keys.
For a public HTTP URL, configure public domains when you create the sandbox.
Sandbox domains suit previews, demos, and webhooks. For production traffic, deploy a service with a public domain.
Pricing
Sandboxes bill at VM rates, metered per second, for CPU used, memory in use, and outbound traffic. Memory includes the operating system and filesystem cache. Waiting on a model or user may reduce CPU use, but memory, background CPU use, and outbound traffic remain billable. Once destroyed, a sandbox incurs no further compute usage.
| Resource | Price |
|---|---|
| Memory | $50 per GB per month (about $0.00000001929 per MB-second), for memory in use while running |
| vCPU | $50 per vCPU per month (about $0.00001929 per vCPU-second), for CPU actually used |
| Egress | $0.05 per GB |
The monthly figures assume continuous use of 1 GB of memory or one vCPU for a 30-day month. Actual charges depend on usage over the sandbox's lifetime.
A worked example: a sandbox that runs a 20-minute agent task, averaging 1 GB of memory in use and 30% of one vCPU, costs about 3 cents in compute. Running 50 such tasks costs about $1.50 in compute. These estimates exclude egress.
Sandbox usage draws from the same included usage as the rest of your plan, and counts toward your usage limits.
To keep costs down:
- Destroy sandboxes when the work is done.
await usingin the SDK does this for you. - Keep the idle timeout short unless a workflow needs the gap.
- Use checkpoints for state you reuse instead of keeping a sandbox alive between runs.
- Close unused CLI SSH and port-forwarding sessions, which keep a sandbox alive.